From 2e897cc8af4234abc4e3f5c3448e1fd7b2b8a1bd Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 5 Dec 2023 08:17:01 +0000 Subject: 1203 trackers oriented view --- .../advancedprivacy/trackers/data/StatsDatabase.kt | 165 +++++++++++---- .../trackers/data/WhitelistRepository.kt | 225 +++++++++++++++++---- .../domain/usecases/FilterHostnameUseCase.kt | 10 +- .../trackers/domain/usecases/StatisticsUseCase.kt | 13 +- 4 files changed, 320 insertions(+), 93 deletions(-) (limited to 'trackers/src/main/java/foundation') diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt index 15ff813..a80d4dc 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt @@ -224,61 +224,112 @@ class StatsDatabase( } } - fun getContactedTrackersCountByAppId(): Map { + fun getCalls(appId: String, periodCount: Int, periodUnit: TemporalUnit): Pair { synchronized(lock) { + val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase - val projection = "$COLUMN_NAME_APPID, $COLUMN_NAME_TRACKER" + val selection = "$COLUMN_NAME_APPID = ? AND " + + "$COLUMN_NAME_TIMESTAMP >= ?" + val selectionArg = arrayOf("" + appId, "" + minTimestamp) + val projection = + "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + + "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" val cursor = db.rawQuery( - "SELECT DISTINCT $projection FROM $TABLE_NAME", // + - arrayOf() + "SELECT $projection FROM $TABLE_NAME WHERE $selection", + selectionArg ) - val countByApp = mutableMapOf() - while (cursor.moveToNext()) { - trackersRepository.getTracker(cursor.getString(COLUMN_NAME_TRACKER))?.let { - val appId = cursor.getString(COLUMN_NAME_APPID) - countByApp[appId] = countByApp.getOrDefault(appId, 0) + 1 - } + var calls: Pair = 0 to 0 + if (cursor.moveToNext()) { + val contacted = cursor.getInt(PROJECTION_NAME_CONTACTED_SUM) + val blocked = cursor.getInt(PROJECTION_NAME_BLOCKED_SUM) + calls = blocked to contacted - blocked } cursor.close() db.close() - return countByApp + return calls } } - fun getCallsByAppIds(periodCount: Int, periodUnit: TemporalUnit): Map> { + fun getMostLeakedAppId(periodCount: Int, periodUnit: TemporalUnit): String { synchronized(lock) { val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase val selection = "$COLUMN_NAME_TIMESTAMP >= ?" val selectionArg = arrayOf("" + minTimestamp) val projection = "$COLUMN_NAME_APPID, " + - "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + - "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" + "SUM($COLUMN_NAME_NUMBER_CONTACTED - $COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_LEAKED_SUM" val cursor = db.rawQuery( "SELECT $projection FROM $TABLE_NAME" + " WHERE $selection" + - " GROUP BY $COLUMN_NAME_APPID", + " GROUP BY $COLUMN_NAME_APPID" + + " ORDER BY $PROJECTION_NAME_LEAKED_SUM DESC LIMIT 1", selectionArg ) - val callsByApp = HashMap>() + var appId = "" + if (cursor.moveToNext()) { + appId = cursor.getString(COLUMN_NAME_APPID) + } + cursor.close() + db.close() + return appId + } + } + + fun getDistinctTrackerAndApp(): List> { + synchronized(lock) { + val db = readableDatabase + val projection = "$COLUMN_NAME_APPID, $COLUMN_NAME_TRACKER" + val cursor = db.rawQuery( + "SELECT DISTINCT $projection FROM $TABLE_NAME", // + + arrayOf() + ) + + val res = mutableListOf>() while (cursor.moveToNext()) { - val contacted = cursor.getInt(PROJECTION_NAME_CONTACTED_SUM) - val blocked = cursor.getInt(PROJECTION_NAME_BLOCKED_SUM) - callsByApp[cursor.getString(COLUMN_NAME_APPID)] = blocked to contacted - blocked + res.add( + cursor.getString(COLUMN_NAME_TRACKER) to cursor.getString(COLUMN_NAME_APPID) + ) } cursor.close() db.close() - return callsByApp + return res } } - fun getCalls(appId: String, periodCount: Int, periodUnit: TemporalUnit): Pair { + suspend fun getApIds(trackerId: String): List = withContext(Dispatchers.IO) { synchronized(lock) { - val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase - val selection = "$COLUMN_NAME_APPID = ? AND " + - "$COLUMN_NAME_TIMESTAMP >= ?" - val selectionArg = arrayOf("" + appId, "" + minTimestamp) + val columns = arrayOf(COLUMN_NAME_APPID, COLUMN_NAME_TRACKER) + val selection = "$COLUMN_NAME_TRACKER = ?" + val selectionArg = arrayOf(trackerId) + val cursor = db.query( + true, + TABLE_NAME, + columns, + selection, + selectionArg, + null, + null, + null, + null + ) + + val apIds: MutableList = ArrayList() + while (cursor.moveToNext()) { + apIds.add(cursor.getString(COLUMN_NAME_APPID)) + } + cursor.close() + db.close() + + apIds + } + } + + suspend fun getCallsForApp(apId: String): Pair = withContext(Dispatchers.IO) { + synchronized(lock) { + val db = readableDatabase + val selection = "$COLUMN_NAME_APPID = ?" + val selectionArg = arrayOf(apId) val projection = "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" @@ -294,32 +345,31 @@ class StatsDatabase( } cursor.close() db.close() - return calls + calls } } - fun getMostLeakedAppId(periodCount: Int, periodUnit: TemporalUnit): String { + suspend fun getCallsForTracker(trackerId: String): Pair = withContext(Dispatchers.IO) { synchronized(lock) { - val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase - val selection = "$COLUMN_NAME_TIMESTAMP >= ?" - val selectionArg = arrayOf("" + minTimestamp) - val projection = "$COLUMN_NAME_APPID, " + - "SUM($COLUMN_NAME_NUMBER_CONTACTED - $COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_LEAKED_SUM" + val selection = "$COLUMN_NAME_TRACKER = ?" + val selectionArg = arrayOf(trackerId) + val projection = + "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + + "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" val cursor = db.rawQuery( - "SELECT $projection FROM $TABLE_NAME" + - " WHERE $selection" + - " GROUP BY $COLUMN_NAME_APPID" + - " ORDER BY $PROJECTION_NAME_LEAKED_SUM DESC LIMIT 1", + "SELECT $projection FROM $TABLE_NAME WHERE $selection", selectionArg ) - var appId = "" + var calls: Pair = 0 to 0 if (cursor.moveToNext()) { - appId = cursor.getString(COLUMN_NAME_APPID) + val contacted = cursor.getInt(PROJECTION_NAME_CONTACTED_SUM) + val blocked = cursor.getInt(PROJECTION_NAME_BLOCKED_SUM) + calls = blocked to contacted - blocked } cursor.close() db.close() - return appId + calls } } @@ -386,7 +436,40 @@ class StatsDatabase( return entry } - fun getTrackers(appIds: List?): List { + suspend fun getTrackerIds(appIds: List?): List = withContext(Dispatchers.IO) { + synchronized(lock) { + val columns = arrayOf(COLUMN_NAME_TRACKER, COLUMN_NAME_APPID) + var selection: String? = null + + var selectionArg: Array? = null + appIds?.let { appIds -> + selection = "$COLUMN_NAME_APPID IN (${appIds.joinToString(", ") { "'$it'" }})" + selectionArg = arrayOf() + } + + val db = readableDatabase + val cursor = db.query( + true, + TABLE_NAME, + columns, + selection, + selectionArg, + null, + null, + null, + null + ) + val trackerIds: MutableList = mutableListOf() + while (cursor.moveToNext()) { + trackerIds.add(cursor.getString(COLUMN_NAME_TRACKER)) + } + cursor.close() + db.close() + trackerIds + } + } + + suspend fun getTrackers(appIds: List?): List = withContext(Dispatchers.IO) { synchronized(lock) { val columns = arrayOf(COLUMN_NAME_TRACKER, COLUMN_NAME_APPID) var selection: String? = null @@ -419,7 +502,7 @@ class StatsDatabase( } cursor.close() db.close() - return trackers + trackers } } diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt index 429c5e9..9f37a1d 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt @@ -23,6 +23,9 @@ import android.content.SharedPreferences import foundation.e.advancedprivacy.data.repositories.AppListsRepository import foundation.e.advancedprivacy.domain.entities.ApplicationDescription import foundation.e.advancedprivacy.trackers.domain.entities.Tracker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import java.io.File class WhitelistRepository( @@ -32,18 +35,25 @@ class WhitelistRepository( private var appsWhitelist: Set = HashSet() private var appUidsWhitelist: Set = HashSet() - private var trackersWhitelistByApp: MutableMap> = HashMap() - private var trackersWhitelistByUid: Map> = HashMap() + private var trackersWhitelist: Set = HashSet() + + private var apIdTrackersWhitelist: Map = emptyMap() + private var appUidTrackersWhitelist: Map = emptyMap() private val prefs: SharedPreferences companion object { - private const val SHARED_PREFS_FILE = "trackers_whitelist_v2" + private const val SHARED_PREFS_FILE = "trackers_whitelist_v3" private const val KEY_BLOCKING_ENABLED = "blocking_enabled" private const val KEY_APPS_WHITELIST = "apps_whitelist" - private const val KEY_APP_TRACKERS_WHITELIST_PREFIX = "app_trackers_whitelist_" + private const val KEY_TRACKERS_WHITELIST = "trackers_whitelist" + private const val KEY_APP_TRACKER_WHITELIST = "app_tracker_whitelist" + private const val KEY_APP_TRACKER_BLACKLIST = "app_tracker_blacklist" + // Deprecated keys. private const val SHARED_PREFS_FILE_V1 = "trackers_whitelist.prefs" + private const val SHARED_PREFS_FILE_V2 = "trackers_whitelist_v2" + private const val KEY_APP_TRACKERS_WHITELIST_PREFIX = "app_trackers_whitelist_" } init { @@ -56,6 +66,9 @@ class WhitelistRepository( if (context.sharedPreferencesExists(SHARED_PREFS_FILE_V1)) { migrate1To2(context) } + if (context.sharedPreferencesExists(SHARED_PREFS_FILE_V2)) { + migrate2To3(context) + } } private fun Context.sharedPreferencesExists(fileName: String): Boolean { @@ -86,7 +99,7 @@ class WhitelistRepository( val apId = appListsRepository.getApp(uid)?.apId apId?.let { val trackers = prefsV1.getStringSet(key, emptySet()) - editorV2.putStringSet(buildAppTrackersKey(apId), trackers) + editorV2.putStringSet(KEY_APP_TRACKERS_WHITELIST_PREFIX + apId, trackers) } } catch (e: Exception) { } } @@ -98,10 +111,39 @@ class WhitelistRepository( reloadCache() } + private fun migrate2To3(context: Context) { + val prefsV2 = context.getSharedPreferences(SHARED_PREFS_FILE_V1, Context.MODE_PRIVATE) + val editorV3 = prefs.edit() + + editorV3.putBoolean(KEY_BLOCKING_ENABLED, prefsV2.getBoolean(KEY_BLOCKING_ENABLED, false)) + + prefsV2.getStringSet(KEY_APPS_WHITELIST, null)?.let { + editorV3.putStringSet(KEY_APPS_WHITELIST, it) + } + editorV3.commit() + + runBlocking { + prefsV2.all.keys.forEach { key -> + if (key.startsWith(KEY_APP_TRACKERS_WHITELIST_PREFIX)) { + runCatching { + val apId = key.substring(KEY_APP_TRACKERS_WHITELIST_PREFIX.length) + prefsV2.getStringSet(key, null) + ?.map { trackerId -> buildApIdTrackerKey(apId, trackerId) } + ?.let { setWhitelisted(it, true) } + } + } + } + } + + context.deleteSharedPreferences(SHARED_PREFS_FILE_V2) + + reloadCache() + } + private fun reloadCache() { isBlockingEnabled = prefs.getBoolean(KEY_BLOCKING_ENABLED, false) reloadAppsWhiteList() - reloadAllAppTrackersWhiteList() + reloadAppTrackersWhitelist() } private fun reloadAppsWhiteList() { @@ -111,24 +153,28 @@ class WhitelistRepository( .toSet() } - private fun refreshAppUidTrackersWhiteList() { - trackersWhitelistByUid = trackersWhitelistByApp.mapNotNull { (apId, value) -> + private fun reloadTrackersWhiteList() { + trackersWhitelist = prefs.getStringSet(KEY_TRACKERS_WHITELIST, HashSet()) ?: HashSet() + } + + private fun reloadAppTrackersWhitelist() { + val whitelist = mutableMapOf() + prefs.getStringSet(KEY_APP_TRACKER_WHITELIST, HashSet())?.forEach { key -> + whitelist[key] = true + } + + prefs.getStringSet(KEY_APP_TRACKER_BLACKLIST, HashSet())?.forEach { key -> + whitelist[key] = false + } + + apIdTrackersWhitelist = whitelist + appUidTrackersWhitelist = whitelist.mapNotNull { (apIdTrackerId, isWhitelisted) -> + val (apId, tracker) = parseApIdTrackerKey(apIdTrackerId) appListsRepository.getApp(apId)?.uid?.let { uid -> - uid to value + buildAppUidTrackerKey(uid, tracker) to isWhitelisted } }.toMap() } - private fun reloadAllAppTrackersWhiteList() { - val map: MutableMap> = HashMap() - prefs.all.keys.forEach { key -> - if (key.startsWith(KEY_APP_TRACKERS_WHITELIST_PREFIX)) { - map[key.substring(KEY_APP_TRACKERS_WHITELIST_PREFIX.length)] = ( - prefs.getStringSet(key, HashSet()) ?: HashSet() - ) - } - } - trackersWhitelistByApp = map - } var isBlockingEnabled: Boolean = false get() = field @@ -149,34 +195,83 @@ class WhitelistRepository( reloadAppsWhiteList() } - private fun buildAppTrackersKey(apId: String): String { - return KEY_APP_TRACKERS_WHITELIST_PREFIX + apId - } + private suspend fun setWhitelisted(keys: List, isWhitelisted: Boolean) = withContext(Dispatchers.IO) { + val whitelist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_WHITELIST, HashSet())?.let { addAll(it) } + } - fun setWhiteListed(tracker: Tracker, apId: String, isWhiteListed: Boolean) { - val trackers = trackersWhitelistByApp.getOrDefault(apId, HashSet()) - trackersWhitelistByApp[apId] = trackers + val blacklist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_BLACKLIST, HashSet())?.let { addAll(it) } + } - if (isWhiteListed) { - trackers.add(tracker.id) + if (isWhitelisted) { + blacklist.removeAll(keys) + whitelist.addAll(keys) } else { - trackers.remove(tracker.id) + whitelist.removeAll(keys) + blacklist.addAll(keys) } - refreshAppUidTrackersWhiteList() - prefs.edit().putStringSet(buildAppTrackersKey(apId), trackers).commit() + + prefs.edit().apply { + putStringSet(KEY_APP_TRACKER_BLACKLIST, blacklist) + putStringSet(KEY_APP_TRACKER_WHITELIST, whitelist) + commit() + } + reloadAppTrackersWhitelist() + } + + suspend fun setWhiteListed(tracker: Tracker, apId: String, isWhitelisted: Boolean) { + setWhitelisted(listOf(buildApIdTrackerKey(apId, tracker.id)), isWhitelisted) + } + + suspend fun setWhitelistedTrackersForApp(apId: String, trackerIds: List, isWhitelisted: Boolean) = withContext( + Dispatchers.IO + ) { + setWhitelisted( + trackerIds.map { trackerId -> buildApIdTrackerKey(apId, trackerId) }, isWhitelisted + ) + } + + suspend fun setWhitelistedAppsForTracker(apIds: List, trackerId: String, isWhitelisted: Boolean) = withContext( + Dispatchers.IO + ) { + setWhitelisted( + apIds.map { apId -> buildApIdTrackerKey(apId, trackerId) }, + isWhitelisted + ) } fun isAppWhiteListed(app: ApplicationDescription): Boolean { return appsWhitelist.contains(app.apId) } - fun isWhiteListed(appUid: Int, trackerId: String?): Boolean { - return appUidsWhitelist.contains(appUid) || - trackersWhitelistByUid.getOrDefault(appUid, HashSet()).contains(trackerId) + fun isAppWhiteListed(appUid: Int): Boolean { + return appUidsWhitelist.contains(appUid) + } + + fun isWhiteListed(appUid: Int, trackerId: String?): Boolean? { + trackerId ?: return null + + val key = buildAppUidTrackerKey(appUid, trackerId) + return appUidTrackersWhitelist.get(key) + } + + private fun buildApIdTrackerKey(apId: String, trackerId: String): String { + return "$apId|$trackerId" + } + + private fun parseApIdTrackerKey(key: String): Pair { + return key.split("|").let { it[0] to it[1] } + } + + private fun buildAppUidTrackerKey(appUid: Int, trackerId: String): String { + return "$appUid-$trackerId" } fun areWhiteListEmpty(): Boolean { - return appsWhitelist.isEmpty() && trackersWhitelistByApp.all { (_, trackers) -> trackers.isEmpty() } + return appsWhitelist.isEmpty() && + trackersWhitelist.isEmpty() && + apIdTrackersWhitelist.values.none { it } } fun getWhiteListedApp(): List { @@ -184,12 +279,64 @@ class WhitelistRepository( } fun getWhiteListForApp(app: ApplicationDescription): List { - return trackersWhitelistByApp[app.apId]?.toList() ?: emptyList() + return apIdTrackersWhitelist.entries.mapNotNull { (key, isWhitelisted) -> + if (!isWhitelisted) { + null + } else { + val (apId, tracker) = parseApIdTrackerKey(key) + if (apId == app.apId) { + tracker + } else { + null + } + } + } } fun clearWhiteList(apId: String) { - trackersWhitelistByApp.remove(apId) - refreshAppUidTrackersWhiteList() - prefs.edit().remove(buildAppTrackersKey(apId)).commit() + val (whitelistToRemove, blacklistToRemove) = apIdTrackersWhitelist.entries + .filter { (key, _) -> key.startsWith(apId) } + .partition { (_, whitelisted) -> whitelisted }.let { (whitelistEntries, blacklistEntries) -> + whitelistEntries.map { it.key }.toSet() to + blacklistEntries.map { it.key }.toSet() + } + + val whitelist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_WHITELIST, HashSet())?.let { addAll(it) } + } + + val blacklist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_BLACKLIST, HashSet())?.let { addAll(it) } + } + + whitelist.removeAll(whitelistToRemove) + blacklist.removeAll(blacklistToRemove) + + prefs.edit().apply { + putStringSet(KEY_APP_TRACKER_WHITELIST, whitelist) + putStringSet(KEY_APP_TRACKER_BLACKLIST, blacklist) + commit() + } + reloadAppTrackersWhitelist() + } + + fun setWhiteListed(tracker: Tracker, isWhiteListed: Boolean) { + val current = prefs.getStringSet(KEY_TRACKERS_WHITELIST, HashSet())?.toHashSet() ?: HashSet() + + if (isWhiteListed) { + current.add(tracker.id) + } else { + current.remove(tracker.id) + } + prefs.edit().putStringSet(KEY_TRACKERS_WHITELIST, current).commit() + reloadTrackersWhiteList() + } + + fun isWhiteListed(tracker: Tracker): Boolean { + return trackersWhitelist.contains(tracker.id) + } + + fun isTrackerWhiteListed(trackerId: String): Boolean { + return trackersWhitelist.contains(trackerId) } } diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt index e229cab..e0fae43 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt @@ -81,7 +81,15 @@ class FilterHostnameUseCase( private fun shouldBlock(appUid: Int, trackerId: String?): Boolean { return whitelistRepository.isBlockingEnabled && - !whitelistRepository.isWhiteListed(appUid, trackerId) + trackerId != null && + !isWhitelisted(appUid, trackerId) + } + + fun isWhitelisted(appUid: Int, trackerId: String): Boolean { + return whitelistRepository.isWhiteListed(appUid, trackerId) ?: ( + whitelistRepository.isTrackerWhiteListed(trackerId) || + whitelistRepository.isAppWhiteListed(appUid) + ) } private val queue = LinkedBlockingQueue() diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt index e7a84b8..22bd8fc 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt @@ -39,25 +39,14 @@ class StatisticsUseCase( return database.getActiveTrackersByPeriod(periodsCount, periodUnit) } - fun getContactedTrackersCountByApp(): Map { - return database.getContactedTrackersCountByAppId().mapByAppIdToApp() - } - fun getContactedTrackersCount(): Int { return database.getContactedTrackersCount() } - fun getTrackers(apps: List?): List { + suspend fun getTrackers(apps: List?): List { return database.getTrackers(apps?.map { it.apId }) } - fun getCallsByApps( - periodCount: Int, - periodUnit: TemporalUnit - ): Map> { - return database.getCallsByAppIds(periodCount, periodUnit).mapByAppIdToApp() - } - fun getCalls(app: ApplicationDescription, periodCount: Int, periodUnit: TemporalUnit): Pair { return database.getCalls(app.apId, periodCount, periodUnit) } -- cgit v1.2.1