/* * Open TimeLimit Copyright 2019 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package io.timelimit.android.logic import android.util.Log import android.util.SparseArray import android.util.SparseLongArray import androidx.lifecycle.LiveData import io.timelimit.android.BuildConfig import io.timelimit.android.R import io.timelimit.android.async.Threads import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.runAsync import io.timelimit.android.coroutines.runAsyncExpectForever import io.timelimit.android.data.backup.DatabaseBackup import io.timelimit.android.data.model.* import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.integration.platform.AppStatusMessage import io.timelimit.android.integration.platform.ForegroundAppSpec import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.android.AccessibilityService import io.timelimit.android.integration.platform.android.AndroidIntegrationApps import io.timelimit.android.livedata.* import io.timelimit.android.sync.actions.UpdateDeviceStatusAction import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.util.AndroidVersion import io.timelimit.android.util.TimeTextUtil import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.* class BackgroundTaskLogic(val appLogic: AppLogic) { var pauseBackgroundLoop = false companion object { private const val CHECK_PERMISSION_INTERVAL = 10 * 1000L // all 10 seconds private const val BACKGROUND_SERVICE_INTERVAL = 100L // all 100 ms private const val MAX_USED_TIME_PER_ROUND = 1000 // 1 second private const val LOG_TAG = "BackgroundTaskLogic" } private val temporarilyAllowedApps = appLogic.database.temporarilyAllowedApp().getTemporarilyAllowedApps() init { runAsyncExpectForever { backgroundServiceLoop() } runAsyncExpectForever { syncDeviceStatusLoop() } runAsyncExpectForever { backupDatabaseLoop() } runAsync { // this is effective after an reboot if (appLogic.deviceEntryIfEnabled.waitForNullableValue() != null) { appLogic.platformIntegration.setEnableSystemLockdown(true) } } appLogic.deviceEntryIfEnabled .map { it?.id } .ignoreUnchanged() .observeForever { _ -> runAsync { syncInstalledAppVersion() } } temporarilyAllowedApps.map { it.isNotEmpty() }.ignoreUnchanged().observeForever { appLogic.platformIntegration.setShowNotificationToRevokeTemporarilyAllowedApps(it!!) } } private val deviceUserEntryLive = SingleItemLiveDataCache(appLogic.deviceUserEntry.ignoreUnchanged()) private val childCategories = object: MultiKeyLiveDataCache, String?>() { // key = child id override fun createValue(key: String?): LiveData> { if (key == null) { // this should rarely happen return liveDataFromValue(Collections.emptyList()) } else { return appLogic.database.category().getCategoriesByChildId(key).ignoreUnchanged() } } } private val appCategories = object: MultiKeyLiveDataCache>>() { // key = package name, category ids override fun createValue(key: Pair>): LiveData { return appLogic.database.categoryApp().getCategoryApp(key.second, key.first) } } private val timeLimitRules = object: MultiKeyLiveDataCache, String>() { override fun createValue(key: String): LiveData> { return appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(key) } } private val usedTimesOfCategoryAndWeekByFirstDayOfWeek = object: MultiKeyLiveDataCache, Pair>() { override fun createValue(key: Pair): LiveData> { return appLogic.database.usedTimes().getUsedTimesOfWeek(key.first, key.second) } } private val shouldDoAutomaticSignOut = SingleItemLiveDataCacheWithRequery { -> appLogic.defaultUserLogic.hasAutomaticSignOut()} private val liveDataCaches = LiveDataCaches(arrayOf( deviceUserEntryLive, childCategories, appCategories, timeLimitRules, usedTimesOfCategoryAndWeekByFirstDayOfWeek, shouldDoAutomaticSignOut )) private var usedTimeUpdateHelper: UsedTimeItemBatchUpdateHelper? = null private var previousMainLogicExecutionTime = 0 private var previousMainLoopEndTime = 0L private val dayChangeTracker = DayChangeTracker( timeApi = appLogic.timeApi, longDuration = 1000 * 60 * 10 /* 10 minutes */ ) private val appTitleCache = QueryAppTitleCache(appLogic.platformIntegration) private suspend fun openLockscreen(blockedAppPackageName: String, blockedAppActivityName: String?) { appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( title = appTitleCache.query(blockedAppPackageName), text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) )) appLogic.platformIntegration.setShowBlockingOverlay(true) if (appLogic.platformIntegration.isAccessibilityServiceEnabled()) { if (blockedAppPackageName != appLogic.platformIntegration.getLauncherAppPackageName()) { AccessibilityService.instance?.showHomescreen() delay(100) AccessibilityService.instance?.showHomescreen() delay(100) } } appLogic.platformIntegration.showAppLockScreen(blockedAppPackageName, blockedAppActivityName) } private val foregroundAppSpec = ForegroundAppSpec.newInstance() private suspend fun backgroundServiceLoop() { while (true) { // app must be enabled if (!appLogic.enable.waitForNonNullValue()) { usedTimeUpdateHelper?.commit(appLogic) liveDataCaches.removeAllItems() appLogic.platformIntegration.setAppStatusMessage(null) appLogic.platformIntegration.setShowBlockingOverlay(false) appLogic.enable.waitUntilValueMatches { it == true } continue } // device must be used by a child val deviceUserEntry = deviceUserEntryLive.read().waitForNullableValue() if (deviceUserEntry == null || deviceUserEntry.type != UserType.Child) { usedTimeUpdateHelper?.commit(appLogic) val shouldDoAutomaticSignOut = shouldDoAutomaticSignOut.read() if (shouldDoAutomaticSignOut.waitForNonNullValue()) { appLogic.defaultUserLogic.reportScreenOn(appLogic.platformIntegration.isScreenOn()) appLogic.platformIntegration.setAppStatusMessage( AppStatusMessage( title = appLogic.context.getString(R.string.background_logic_timeout_title), text = appLogic.context.getString(R.string.background_logic_timeout_text), showSwitchToDefaultUserOption = true ) ) appLogic.platformIntegration.setShowBlockingOverlay(false) liveDataCaches.reportLoopDone() appLogic.timeApi.sleep(BACKGROUND_SERVICE_INTERVAL) } else { liveDataCaches.removeAllItems() appLogic.platformIntegration.setAppStatusMessage(null) appLogic.platformIntegration.setShowBlockingOverlay(false) val isChildSignedIn = deviceUserEntryLive.read().map { it != null && it.type == UserType.Child } isChildSignedIn.or(shouldDoAutomaticSignOut).waitUntilValueMatches { it == true } } continue } // loop logic try { // get the current time val nowTimestamp = appLogic.timeApi.getCurrentTimeInMillis() val nowTimezone = TimeZone.getTimeZone(deviceUserEntry.timeZone) val nowDate = DateInTimezone.newInstance(nowTimestamp, nowTimezone) val minuteOfWeek = getMinuteOfWeek(nowTimestamp, nowTimezone) // eventually remove old used time data run { val dayChange = dayChangeTracker.reportDayChange(nowDate.dayOfEpoch) fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems( database = appLogic.database, date = nowDate ) if (dayChange == DayChangeTracker.DayChange.NowSinceLongerTime) { deleteOldUsedTimes() } } // get the categories val categories = childCategories.get(deviceUserEntry.id).waitForNonNullValue() val temporarilyAllowedApps = temporarilyAllowedApps.waitForNonNullValue() // get the current status val isScreenOn = appLogic.platformIntegration.isScreenOn() appLogic.defaultUserLogic.reportScreenOn(isScreenOn) if (!isScreenOn) { if (temporarilyAllowedApps.isNotEmpty()) { resetTemporarilyAllowedApps() } } appLogic.platformIntegration.getForegroundApp(foregroundAppSpec, appLogic.getForegroundAppQueryInterval()) val foregroundAppPackageName = foregroundAppSpec.packageName val foregroundAppActivityName = foregroundAppSpec.activityName val activityLevelBlocking = appLogic.deviceEntry.value?.enableActivityLevelBlocking ?: false fun showStatusMessageWithCurrentAppTitle(text: String, titlePrefix: String? = "") { appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( titlePrefix + appTitleCache.query(foregroundAppPackageName ?: "invalid"), text, if (activityLevelBlocking) foregroundAppActivityName?.removePrefix(foregroundAppPackageName ?: "invalid") else null )) } // the following is not executed if the permission is missing if (pauseBackgroundLoop) { usedTimeUpdateHelper?.commit(appLogic) appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( title = appLogic.context.getString(R.string.background_logic_paused_title), text = appLogic.context.getString(R.string.background_logic_paused_text) )) appLogic.platformIntegration.setShowBlockingOverlay(false) } else if ( (foregroundAppPackageName == BuildConfig.APPLICATION_ID) || (foregroundAppPackageName != null && AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName))) { usedTimeUpdateHelper?.commit(appLogic) showStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_whitelisted) ) appLogic.platformIntegration.setShowBlockingOverlay(false) } else if (foregroundAppPackageName != null && temporarilyAllowedApps.contains(foregroundAppPackageName)) { usedTimeUpdateHelper?.commit(appLogic) showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_temporarily_allowed)) appLogic.platformIntegration.setShowBlockingOverlay(false) } else if (foregroundAppPackageName != null) { val categoryIds = categories.map { it.id } val appCategory = run { val appLevelCategoryLive = appCategories.get(foregroundAppPackageName to categoryIds) if (activityLevelBlocking) { val appActivityCategoryLive = appCategories.get("$foregroundAppPackageName:$foregroundAppActivityName" to categoryIds) appActivityCategoryLive.waitForNullableValue() ?: appLevelCategoryLive.waitForNullableValue() } else { appLevelCategoryLive.waitForNullableValue() } } val category = categories.find { it.id == appCategory?.categoryId } ?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps } val parentCategory = categories.find { it.id == category?.parentCategoryId } if (category == null) { usedTimeUpdateHelper?.commit(appLogic) if (AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName) == false) { // don't suspend system apps which are whitelisted in any version appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true) } openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) { usedTimeUpdateHelper?.commit(appLogic) openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } else { // disable time limits temporarily feature if (nowTimestamp < deviceUserEntry.disableLimitsUntil) { showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_limits_disabled)) appLogic.platformIntegration.setShowBlockingOverlay(false) } else if ( // check blocked time areas // directly blocked (category.blockedMinutesInWeek.read(minuteOfWeek)) or (parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true) ) { usedTimeUpdateHelper?.commit(appLogic) openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } else { // check time limits val rules = timeLimitRules.get(category.id).waitForNonNullValue() val parentRules = parentCategory?.let { timeLimitRules.get(it.id).waitForNonNullValue() } ?: emptyList() if (rules.isEmpty() and parentRules.isEmpty()) { // unlimited usedTimeUpdateHelper?.commit(appLogic) showStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_no_timelimit), titlePrefix = category.title + " - " ) appLogic.platformIntegration.setShowBlockingOverlay(false) } else { val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue() val parentUsedTimes = parentCategory?.let { usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(it.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue() } ?: SparseArray() val newUsedTimeItemBatchUpdateHelper = UsedTimeItemBatchUpdateHelper.eventuallyUpdateInstance( date = nowDate, childCategoryId = category.id, parentCategoryId = parentCategory?.id, oldInstance = usedTimeUpdateHelper, usedTimeItemForDayChild = usedTimes.get(nowDate.dayOfWeek), usedTimeItemForDayParent = parentUsedTimes.get(nowDate.dayOfWeek), logic = appLogic ) usedTimeUpdateHelper = newUsedTimeItemBatchUpdateHelper fun buildUsedTimesSparseArray(items: SparseArray, isParentCategory: Boolean): SparseLongArray { val result = SparseLongArray() for (i in 0..6) { val usedTimesItem = items[i]?.usedMillis if (newUsedTimeItemBatchUpdateHelper.date.dayOfWeek == i) { result.put( i, if (isParentCategory) newUsedTimeItemBatchUpdateHelper.getTotalUsedTimeParent() else newUsedTimeItemBatchUpdateHelper.getTotalUsedTimeChild() ) } else { result.put(i, usedTimesItem ?: 0) } } return result } val remainingChild = RemainingTime.getRemainingTime( nowDate.dayOfWeek, buildUsedTimesSparseArray(usedTimes, isParentCategory = false), rules, Math.max(0, category.extraTimeInMillis - newUsedTimeItemBatchUpdateHelper.getCachedExtraTimeToSubtract()) ) val remainingParent = parentCategory?.let { RemainingTime.getRemainingTime( nowDate.dayOfWeek, buildUsedTimesSparseArray(parentUsedTimes, isParentCategory = true), parentRules, Math.max(0, parentCategory.extraTimeInMillis - newUsedTimeItemBatchUpdateHelper.getCachedExtraTimeToSubtract()) ) } val remaining = RemainingTime.min(remainingChild, remainingParent) if (remaining == null) { // unlimited usedTimeUpdateHelper?.commit(appLogic) showStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_no_timelimit), titlePrefix = category.title + " - " ) appLogic.platformIntegration.setShowBlockingOverlay(false) } else { // time limited if (remaining.includingExtraTime > 0) { var subtractExtraTime: Boolean if (remaining.default == 0L) { // using extra time showStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context)), titlePrefix = category.title + " - " ) subtractExtraTime = true } else { // using normal contingent showStatusMessageWithCurrentAppTitle( text = TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context), titlePrefix = category.title + " - " ) subtractExtraTime = false } appLogic.platformIntegration.setShowBlockingOverlay(false) if (isScreenOn) { // never save more than a second of used time val timeToSubtract = Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND) newUsedTimeItemBatchUpdateHelper.addUsedTime( timeToSubtract, subtractExtraTime, appLogic ) val oldRemainingTime = remaining.includingExtraTime val newRemainingTime = oldRemainingTime - timeToSubtract if (oldRemainingTime / (1000 * 60) != newRemainingTime / (1000 * 60)) { // eventually show remaining time warning val roundedNewTime = ((newRemainingTime / (1000 * 60)) + 1) * (1000 * 60) val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime] if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) { appLogic.platformIntegration.showTimeWarningNotification( title = appLogic.context.getString(R.string.time_warning_not_title, category.title), text = TimeTextUtil.remaining(roundedNewTime.toInt(), appLogic.context) ) } } } } else { // there is not time anymore newUsedTimeItemBatchUpdateHelper.commit(appLogic) openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } } } } } } else { appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.context.getString(R.string.background_logic_idle_title), appLogic.context.getString(R.string.background_logic_idle_text) )) appLogic.platformIntegration.setShowBlockingOverlay(false) } } catch (ex: SecurityException) { // this is handled by an other main loop (with a delay) appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_permission) )) appLogic.platformIntegration.setShowBlockingOverlay(false) } catch (ex: Exception) { if (BuildConfig.DEBUG) { Log.w(LOG_TAG, "exception during running main loop", ex) } appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_internal) )) appLogic.platformIntegration.setShowBlockingOverlay(false) } liveDataCaches.reportLoopDone() // delay before running next time val endTime = appLogic.timeApi.getCurrentUptimeInMillis() previousMainLogicExecutionTime = (endTime - previousMainLoopEndTime).toInt() previousMainLoopEndTime = endTime val timeToWait = Math.max(10, BACKGROUND_SERVICE_INTERVAL - previousMainLogicExecutionTime) appLogic.timeApi.sleep(timeToWait) } } private suspend fun syncInstalledAppVersion() { val currentAppVersion = BuildConfig.VERSION_CODE val deviceEntry = appLogic.deviceEntry.waitForNullableValue() if (deviceEntry != null) { if (deviceEntry.currentAppVersion != currentAppVersion) { ApplyActionUtil.applyAppLogicAction( action = UpdateDeviceStatusAction.empty.copy( newAppVersion = currentAppVersion ), appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } } } fun syncDeviceStatusAsync() { runAsync { syncDeviceStatus() } } private suspend fun syncDeviceStatusLoop() { while (true) { appLogic.deviceEntryIfEnabled.waitUntilValueMatches { it != null } syncDeviceStatus() appLogic.timeApi.sleep(CHECK_PERMISSION_INTERVAL) } } private val syncDeviceStatusLock = Mutex() fun reportDeviceReboot() { runAsync { val deviceEntry = appLogic.deviceEntry.waitForNullableValue() if (deviceEntry?.considerRebootManipulation == true) { ApplyActionUtil.applyAppLogicAction( action = UpdateDeviceStatusAction.empty.copy( didReboot = true ), appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } } } private suspend fun syncDeviceStatus() { syncDeviceStatusLock.withLock { val deviceEntry = appLogic.deviceEntry.waitForNullableValue() if (deviceEntry != null) { val protectionLevel = appLogic.platformIntegration.getCurrentProtectionLevel() val usageStatsPermission = appLogic.platformIntegration.getForegroundAppPermissionStatus() val notificationAccess = appLogic.platformIntegration.getNotificationAccessPermissionStatus() val overlayPermission = appLogic.platformIntegration.getOverlayPermissionStatus() val accessibilityService = appLogic.platformIntegration.isAccessibilityServiceEnabled() val qOrLater = AndroidVersion.qOrLater var changes = UpdateDeviceStatusAction.empty if (protectionLevel != deviceEntry.currentProtectionLevel) { changes = changes.copy( newProtectionLevel = protectionLevel ) if (protectionLevel == ProtectionLevel.DeviceOwner) { appLogic.platformIntegration.setEnableSystemLockdown(true) } } if (usageStatsPermission != deviceEntry.currentUsageStatsPermission) { changes = changes.copy( newUsageStatsPermissionStatus = usageStatsPermission ) } if (notificationAccess != deviceEntry.currentNotificationAccessPermission) { changes = changes.copy( newNotificationAccessPermission = notificationAccess ) } if (overlayPermission != deviceEntry.currentOverlayPermission) { changes = changes.copy( newOverlayPermission = overlayPermission ) } if (accessibilityService != deviceEntry.accessibilityServiceEnabled) { changes = changes.copy( newAccessibilityServiceEnabled = accessibilityService ) } if (qOrLater && !deviceEntry.qOrLater) { changes = changes.copy(isQOrLaterNow = true) } if (changes != UpdateDeviceStatusAction.empty) { ApplyActionUtil.applyAppLogicAction( action = changes, appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } } } } suspend fun resetTemporarilyAllowedApps() { Threads.database.executeAndWait(Runnable { appLogic.database.temporarilyAllowedApp().removeAllTemporarilyAllowedAppsSync() }) } private suspend fun backupDatabaseLoop() { appLogic.timeApi.sleep(1000 * 60 * 5 /* 5 minutes */) while (true) { DatabaseBackup.with(appLogic.context).tryCreateDatabaseBackupAsync() appLogic.timeApi.sleep(1000 * 60 * 60 * 3 /* 3 hours */) } } }