/* * TimeLimit Copyright 2019 - 2023 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.content.pm.PackageManager import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map 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.DevicePlatform import io.timelimit.android.data.model.ExperimentalFlags import io.timelimit.android.data.model.ManipulationFlag import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.derived.UserRelatedData import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.extensions.MinuteOfDay import io.timelimit.android.extensions.nextBlockedMinuteOfWeek import io.timelimit.android.integration.platform.* import io.timelimit.android.integration.platform.android.AccessibilityService import io.timelimit.android.integration.platform.android.BackgroundService import io.timelimit.android.livedata.* import io.timelimit.android.logic.blockingreason.AppBaseHandling import io.timelimit.android.logic.blockingreason.CategoryHandlingCache import io.timelimit.android.logic.blockingreason.CategoryItselfHandling import io.timelimit.android.sync.actions.ForceSyncAction import io.timelimit.android.sync.actions.UpdateDeviceStatusAction import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.ui.lock.LockActivity import io.timelimit.android.util.AndroidVersion import io.timelimit.android.util.TimeTextUtil import io.timelimit.android.work.PeriodicSyncInBackgroundWorker import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import java.util.* class BackgroundTaskLogic(val appLogic: AppLogic) { var pauseForegroundAppBackgroundLoop = false val lastLoopException = MutableLiveData().apply { value = null } private var slowMainLoop = false companion object { private const val LOG_TAG = "BackgroundTaskLogic" private const val CHECK_PERMISSION_INTERVAL = 10 * 1000L // all 10 seconds private const val BACKGROUND_SERVICE_INTERVAL_SHORT = 100L // all 100 ms private const val MAX_USED_TIME_PER_ROUND_SHORT = 1000 // 1 second private const val BACKGROUND_SERVICE_INTERVAL_LONG = 1000L // every second private const val MAX_USED_TIME_PER_ROUND_LONG = 2000 // 1 second const val EXTEND_SESSION_TOLERANCE = 5 * 1000L // 5 seconds } init { runAsyncExpectForever { backgroundServiceLoop() } runAsyncExpectForever { syncDeviceStatusLoop() } runAsyncExpectForever { backupDatabaseLoop() } runAsyncExpectForever { annoyUserOnManipulationLoop() } runAsync { checkForceKilled() } runAsync { // this is effective after an reboot if (appLogic.deviceEntryIfEnabled.waitForNullableValue() != null) { appLogic.platformIntegration.setEnableSystemLockdown(true) } else { appLogic.platformIntegration.setEnableSystemLockdown(false) } } appLogic.deviceEntryIfEnabled .map { it?.id } .ignoreUnchanged() .observeForever { _ -> runAsync { syncInstalledAppVersion() } } appLogic.database.config().getEnableBackgroundSyncAsync().ignoreUnchanged().observeForever { if (it) { PeriodicSyncInBackgroundWorker.enable(appLogic.context) } else { PeriodicSyncInBackgroundWorker.disable(appLogic.context) } } appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.CUSTOM_HOME_SCREEN).observeForever { appLogic.platformIntegration.setEnableCustomHomescreen(it) } appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.NETWORKTIME_AT_SYSTEMLEVEL).observeForever { appLogic.platformIntegration.setForceNetworkTime(it) } appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.HIGH_MAIN_LOOP_DELAY).observeForever { slowMainLoop = it } } private val usedTimeUpdateHelper = UsedTimeUpdateHelper(appLogic) private var previousMainLogicExecutionTime = 0 private var previousMainLoopEndTime = 0L private var previousAudioPlaybackBlock: Pair? = null private var previousMinuteOfWeek = -1 private val dayChangeTracker = DayChangeTracker( timeApi = appLogic.timeApi, longDuration = 1000 * 60 * 10 /* 10 minutes */ ) private val undisturbedCategoryUsageCounter = UndisturbedCategoryUsageCounter() private val appTitleCache = QueryAppTitleCache(appLogic.platformIntegration) private val categoryHandlingCache = CategoryHandlingCache() private val isChromeOs = appLogic.context.packageManager.hasSystemFeature(PackageManager.FEATURE_PC) private suspend fun openLockscreen(blockedAppPackageName: String, blockedAppActivityName: String?, enableSoftBlocking: Boolean) { if (enableSoftBlocking) { appLogic.platformIntegration.setShowBlockingOverlay(false) } else { appLogic.platformIntegration.setShowBlockingOverlay(true, "$blockedAppPackageName:${blockedAppActivityName?.removePrefix(blockedAppPackageName)}") } if (isChromeOs) { LockActivity.currentInstances.forEach { it.finish() } var i = 0 while (LockActivity.currentInstances.isNotEmpty() && i < 2000) { delay(10) i += 10 } } if (appLogic.platformIntegration.isAccessibilityServiceEnabled() && !enableSoftBlocking) { if (blockedAppPackageName != appLogic.platformIntegration.getLauncherAppPackageName()) { AccessibilityService.instance?.showHomescreen() delay(100) } } appLogic.platformIntegration.showAppLockScreen(blockedAppPackageName, blockedAppActivityName) } private var showNotificationToRevokeTemporarilyAllowedApps: Boolean? = null private fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) { if (showNotificationToRevokeTemporarilyAllowedApps != show) { showNotificationToRevokeTemporarilyAllowedApps = show appLogic.platformIntegration.setShowNotificationToRevokeTemporarilyAllowedApps(show) } } private suspend fun commitUsedTimeUpdaters() { usedTimeUpdateHelper.flush() } private suspend fun backgroundServiceLoop() { val realTime = RealTime.newInstance() var skipBackgroundRestrictionChecks = 0 while (true) { val backgroundServiceInterval = when (slowMainLoop) { true -> BACKGROUND_SERVICE_INTERVAL_LONG false -> BACKGROUND_SERVICE_INTERVAL_SHORT } val maxUsedTimeToAdd = when (slowMainLoop) { true -> MAX_USED_TIME_PER_ROUND_LONG false -> MAX_USED_TIME_PER_ROUND_SHORT } // app must be enabled if (!appLogic.enable.waitForNonNullValue()) { commitUsedTimeUpdaters() undisturbedCategoryUsageCounter.reset() appLogic.platformIntegration.setAppStatusMessage(null) appLogic.platformIntegration.setShowBlockingOverlay(false) setShowNotificationToRevokeTemporarilyAllowedApps(false) appLogic.enable.waitUntilValueMatches { it == true } continue } val deviceAndUSerRelatedData = Threads.database.executeAndWait { appLogic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() } val deviceRelatedData = deviceAndUSerRelatedData?.deviceRelatedData val userRelatedData = deviceAndUSerRelatedData?.userRelatedData val showSyncRelatedNotifications = deviceAndUSerRelatedData?.deviceRelatedData?.isExperimentalFlagSetSync(ExperimentalFlags.SYNC_RELATED_NOTIFICATIONS) ?: false setShowNotificationToRevokeTemporarilyAllowedApps(deviceRelatedData?.temporarilyAllowedApps?.isNotEmpty() ?: false) // device must be used by a child if (deviceRelatedData == null || userRelatedData == null || userRelatedData.user.type != UserType.Child) { commitUsedTimeUpdaters() undisturbedCategoryUsageCounter.reset() val shouldDoAutomaticSignOut = deviceRelatedData != null && DefaultUserLogic.hasAutomaticSignOut(deviceRelatedData) && deviceRelatedData.canSwitchToDefaultUser if (shouldDoAutomaticSignOut) { 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) appLogic.timeApi.sleep(backgroundServiceInterval) } else { appLogic.platformIntegration.setAppStatusMessage(null) appLogic.platformIntegration.setShowBlockingOverlay(false) appLogic.timeApi.sleep(backgroundServiceInterval) } continue } // loop logic try { if (skipBackgroundRestrictionChecks <= 0) { if (BackgroundService.isBackgroundActivityRestricted(appLogic.context)) { commitUsedTimeUpdaters() undisturbedCategoryUsageCounter.reset() appLogic.platformIntegration.setShowBlockingOverlay(false) appLogic.platformIntegration.setAppStatusMessage( AppStatusMessage( appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_permission) ) ) appLogic.timeApi.sleep(BACKGROUND_SERVICE_INTERVAL_LONG) continue } else skipBackgroundRestrictionChecks = 128 } else skipBackgroundRestrictionChecks-- // get the current time appLogic.realTimeLogic.getRealTime(realTime) val nowTimestamp = realTime.timeInMillis val nowTimezone = TimeZone.getTimeZone(userRelatedData.user.timeZone) val nowUptime = appLogic.timeApi.getCurrentUptimeInMillis() val nowDate = DateInTimezone.getLocalDate(nowTimestamp, nowTimezone) val nowMinuteOfWeek = getMinuteOfWeek(nowTimestamp, nowTimezone) val lastMinuteOfWeek = previousMinuteOfWeek.let { if (it < 0) nowMinuteOfWeek else it } previousMinuteOfWeek = nowMinuteOfWeek val dayOfEpoch = nowDate.toEpochDay().toInt() // eventually remove old used time data if (realTime.shouldTrustTimePermanently) { val dayChange = dayChangeTracker.reportDayChange(dayOfEpoch) fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems( database = appLogic.database, date = DateInTimezone.newInstance(nowDate), timestamp = nowTimestamp ) if (realTime.isNetworkTime) { if (dayChange == DayChangeTracker.DayChange.Now) { deleteOldUsedTimes() } } else { if (dayChange == DayChangeTracker.DayChange.NowSinceLongerTime) { deleteOldUsedTimes() } } } // get the current status val isScreenOn = appLogic.platformIntegration.isScreenOn() val batteryStatus = appLogic.platformIntegration.getBatteryStatus() appLogic.defaultUserLogic.reportScreenOn(isScreenOn) if (!isScreenOn) { if (deviceRelatedData.temporarilyAllowedApps.isNotEmpty()) { resetTemporarilyAllowedApps() } } val foregroundAppsOrNullOnMissingPermission = try { appLogic.platformIntegration.getForegroundApps( appLogic.getForegroundAppQueryInterval(), deviceRelatedData.experimentalFlags ) } catch (ex: SecurityException) { lastLoopException.postValue(ex) null } val audioPlaybackPackageName = appLogic.platformIntegration.getMusicPlaybackPackage() val activityLevelBlocking = appLogic.deviceEntry.value?.enableActivityLevelBlocking ?: false val foregroundAppWithBaseHandlings = if (foregroundAppsOrNullOnMissingPermission != null) { foregroundAppsOrNullOnMissingPermission.map { app -> app to AppBaseHandling.calculate( foregroundAppPackageName = app.packageName, foregroundAppActivityName = app.activityName, pauseForegroundAppBackgroundLoop = pauseForegroundAppBackgroundLoop, userRelatedData = userRelatedData, deviceRelatedData = deviceRelatedData, pauseCounting = !isScreenOn, isSystemImageApp = appLogic.platformIntegration.isSystemImageApp(app.packageName) ) } } else listOf( ForegroundApp(DummyApps.MISSING_PERMISSION_APP, null) to AppBaseHandling.SanctionCountEverything( categoryIds = userRelatedData.categoryById.keys ) ) val backgroundAppBaseHandling = AppBaseHandling.calculate( foregroundAppPackageName = audioPlaybackPackageName, foregroundAppActivityName = DummyApps.ACTIVITY_BACKGROUND_AUDIO, pauseForegroundAppBackgroundLoop = false, userRelatedData = userRelatedData, deviceRelatedData = deviceRelatedData, pauseCounting = false, isSystemImageApp = audioPlaybackPackageName?.let { appLogic.platformIntegration.isSystemImageApp(it) } ?: false ) val allAppsBaseHandlings = foregroundAppWithBaseHandlings.map { it.second } + listOf(backgroundAppBaseHandling) undisturbedCategoryUsageCounter.report( nowUptime, AppBaseHandling.getCategories( allAppsBaseHandlings, AppBaseHandling.GetCategoriesPurpose.DelayedSessionDurationCounting ) ) val recentlyStartedCategories = undisturbedCategoryUsageCounter.getRecentlyStartedCategories(nowUptime) val needsNetworkId = allAppsBaseHandlings.find { it.needsNetworkId } != null val networkId: NetworkId? = if (needsNetworkId) appLogic.platformIntegration.getCurrentNetworkId() else null fun reportStatusToCategoryHandlingCache(userRelatedData: UserRelatedData) { categoryHandlingCache.reportStatus( user = userRelatedData, timeInMillis = nowTimestamp, shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily, assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice(deviceRelatedData, userRelatedData), batteryStatus = batteryStatus, currentNetworkId = networkId, hasPremiumOrLocalMode = deviceRelatedData.isLocalMode || deviceRelatedData.isConnectedAndHasPremium ) }; reportStatusToCategoryHandlingCache(userRelatedData) // check if should be blocked val blockedForegroundApp = foregroundAppWithBaseHandlings.find { (_, foregroundAppBaseHandling) -> val noCategoryBlocking = foregroundAppBaseHandling is AppBaseHandling.BlockDueToNoCategory val byCategoryBlocking = foregroundAppBaseHandling .getCategories(AppBaseHandling.GetCategoriesPurpose.Blocking) .find { categoryHandlingCache.get(it).shouldBlockActivities } != null noCategoryBlocking || byCategoryBlocking }?.first val blockAudioPlayback = kotlin.run { val noCategoryBlocking = backgroundAppBaseHandling is AppBaseHandling.BlockDueToNoCategory val hasPremium = deviceRelatedData.isLocalMode || deviceRelatedData.isConnectedAndHasPremium val byCategoryBlocking = backgroundAppBaseHandling .getCategories(AppBaseHandling.GetCategoriesPurpose.Blocking) .find { val handling = categoryHandlingCache.get(it) val blockAllNotifications = handling.blockAllNotifications is CategoryItselfHandling.BlockAllNotifications.Yes && hasPremium handling.shouldBlockActivities || blockAllNotifications } != null noCategoryBlocking || byCategoryBlocking } // update times val timeToSubtract = Math.min(previousMainLogicExecutionTime, maxUsedTimeToAdd) val categoryIdsToCount = AppBaseHandling.getCategories( allAppsBaseHandlings, AppBaseHandling.GetCategoriesPurpose.UsageCounting ) val categoryHandlingsToCount = categoryIdsToCount .map { categoryHandlingCache.get(it) } .filter { it.shouldCountTime } fun timeToSubtractForCategory(categoryId: String): Int { return if (usedTimeUpdateHelper.getCountedCategoryIds().contains(categoryId)) usedTimeUpdateHelper.getCountedTime() else 0 } val triggerSync = kotlin.run { var triggerSyncByLimitLoginCategory = false var triggerSyncByTimeOver = false categoryHandlingsToCount.forEach { handling -> val category = handling.createdWithCategoryRelatedData.category val categoryId = category.id val timeToSubtractForCategory = timeToSubtractForCategory(categoryId) val isRecentlyStartedCategory = recentlyStartedCategories.contains(categoryId) val nowRemaining = handling.remainingTime ?: return@forEach // category is not limited anymore val oldRemainingTime = nowRemaining.includingExtraTime - timeToSubtractForCategory val newRemainingTime = oldRemainingTime - timeToSubtract val commitedSessionDuration = handling.remainingSessionDuration val oldSessionDuration = handling.remainingSessionDuration?.let { it - timeToSubtractForCategory } // trigger time warnings fun handleTimeWarnings( notificationTitleStringResource: Int, roundedNewTimeInMilliseconds: Long ) { val roundedNewTimeInMinutes = roundedNewTimeInMilliseconds / (1000 * 60) if ( // CategoryTimeWarning.MAX is still small enough for an integer roundedNewTimeInMilliseconds >= 0 && roundedNewTimeInMilliseconds < Int.MAX_VALUE && roundedNewTimeInMinutes >= 0 && roundedNewTimeInMinutes < Int.MAX_VALUE && handling.createdWithCategoryRelatedData.allTimeWarningMinutes.contains( roundedNewTimeInMinutes.toInt() ) ) { appLogic.platformIntegration.showTimeWarningNotification( title = appLogic.context.getString( notificationTitleStringResource, category.title ), text = TimeTextUtil.remaining( roundedNewTimeInMilliseconds.toInt(), appLogic.context ) ) } } if (oldRemainingTime / (1000 * 60) != newRemainingTime / (1000 * 60)) { handleTimeWarnings( notificationTitleStringResource = R.string.time_warning_not_title, roundedNewTimeInMilliseconds = ((newRemainingTime / (1000 * 60)) + 1) * 1000 * 60 ) } if (oldSessionDuration != null) { val newSessionDuration = oldSessionDuration - timeToSubtract if (oldSessionDuration / (1000 * 60) != newSessionDuration / (1000 * 60)) { handleTimeWarnings( notificationTitleStringResource = R.string.time_warning_not_title_session, roundedNewTimeInMilliseconds = ((newSessionDuration / (1000 * 60)) + 1) * (1000 * 60) ) } } if (handling.okByBlockedTimeAreas && nowMinuteOfWeek != lastMinuteOfWeek) { val nextBlockedMinute = handling.createdWithCategoryRelatedData.nextBlockedMinuteOfWeek(nowMinuteOfWeek) ?: run { handling.createdWithCategoryRelatedData.nextBlockedMinuteOfWeek(0)?.let { MinuteOfDay.LENGTH * 7 + it } } if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "next blocked minute: $nextBlockedMinute (current: $nowMinuteOfWeek)") } if (nextBlockedMinute != null) { val minutesUntilNextBlockedMinute = nextBlockedMinute - nowMinuteOfWeek handleTimeWarnings( notificationTitleStringResource = R.string.time_warning_not_title_blocked_time_area, roundedNewTimeInMilliseconds = minutesUntilNextBlockedMinute.toLong() * 1000 * 60 ) } } // check if sync triggered by time over if (oldRemainingTime > 0 && newRemainingTime <= 0) { triggerSyncByTimeOver = true } // check if sync triggered by session duration reached if (oldSessionDuration != null) { val newSessionDuration = oldSessionDuration - timeToSubtract if (oldSessionDuration > 0 && newSessionDuration <= 0 && !isRecentlyStartedCategory) { triggerSyncByTimeOver = true } } // check if limit login triggered val triggerSyncByLimitLoginCategoryForThisCategory = userRelatedData.preBlockSwitchPoints.let { switchPoints -> if (switchPoints.isEmpty()) false else { val limitLoginBySessionDuration = if (commitedSessionDuration != null && oldSessionDuration != null) { val newSessionDuration = oldSessionDuration - timeToSubtract switchPoints.find { switchPoint -> switchPoint in newSessionDuration until commitedSessionDuration } != null } else false val limitLoginByRemainingTime = switchPoints.find { switchPoint -> oldRemainingTime >= switchPoint && newRemainingTime < switchPoint } != null (limitLoginBySessionDuration && !isRecentlyStartedCategory) || limitLoginByRemainingTime } } triggerSyncByLimitLoginCategory = triggerSyncByLimitLoginCategory || triggerSyncByLimitLoginCategoryForThisCategory } if (BuildConfig.DEBUG) { if (triggerSyncByTimeOver) { Log.d(LOG_TAG, "trigger sync because the time is over") } if (triggerSyncByLimitLoginCategory) { Log.d(LOG_TAG, "trigger sync by the limit login category") } } if (showSyncRelatedNotifications) { if (triggerSyncByTimeOver) { appLogic.platformIntegration.showOverlayMessage("trigger sync because the time is over") } if (triggerSyncByLimitLoginCategory) { appLogic.platformIntegration.showOverlayMessage("trigger sync by the limit login category") } } val triggerSync = triggerSyncByLimitLoginCategory || triggerSyncByTimeOver triggerSync } val didAutoCommitOfUsedTimes = usedTimeUpdateHelper.report( duration = timeToSubtract, dayOfEpoch = dayOfEpoch, trustedTimestamp = if (realTime.shouldTrustTimePermanently) realTime.timeInMillis else 0, handlings = categoryHandlingsToCount, recentlyStartedCategories = recentlyStartedCategories ) if (didAutoCommitOfUsedTimes) { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "auto commit used times") } } // trigger sync when required if (triggerSync) { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "trigger sync") } if (showSyncRelatedNotifications) { appLogic.platformIntegration.showOverlayMessage("sync forced") } usedTimeUpdateHelper.doCommit() ApplyActionUtil.applyAppLogicAction( action = ForceSyncAction, appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } if (didAutoCommitOfUsedTimes || triggerSync) { val newDeviceAndUserRelatedData = Threads.database.executeAndWait { appLogic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() } if ( newDeviceAndUserRelatedData?.userRelatedData?.user?.id != deviceAndUSerRelatedData.userRelatedData.user.id || newDeviceAndUserRelatedData.userRelatedData.categoryById.keys != deviceAndUSerRelatedData.userRelatedData.categoryById.keys ) { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "restart the loop") } // start the loop directly again continue } reportStatusToCategoryHandlingCache(userRelatedData = newDeviceAndUserRelatedData.userRelatedData) } // show notification fun buildStatusMessageWithCurrentAppTitle( text: String, titlePrefix: String = "", titleSuffix: String = "", appPackageName: String?, appActivityToShow: String? ) = AppStatusMessage( title = titlePrefix + appTitleCache.query(appPackageName ?: "invalid") + titleSuffix, text = text, subtext = if (appActivityToShow != null && appPackageName != null) appActivityToShow.removePrefix(appPackageName) else null, showSwitchToDefaultUserOption = deviceRelatedData.canSwitchToDefaultUser ) fun getCategoryTitle(categoryId: String?): String = categoryId.let { userRelatedData.categoryById[it]?.category?.title } ?: categoryId.toString() fun buildNotificationForAppWithCategoryUsage( suffix: String, appPackageName: String?, appActivityToShow: String?, categoryId: String ): AppStatusMessage { val handling = categoryHandlingCache.get(categoryId) val titlePrefix = getCategoryTitle(categoryId) + " - " return if (handling.areLimitsTemporarilyDisabled) { buildStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_limits_disabled), titlePrefix = titlePrefix, titleSuffix = suffix, appPackageName = appPackageName, appActivityToShow = appActivityToShow ) } else if (handling.remainingTime == null) { buildStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_no_timelimit), titlePrefix = titlePrefix, titleSuffix = suffix, appPackageName = appPackageName, appActivityToShow = appActivityToShow ) } else { val remainingTimeFromCache = handling.remainingTime val timeSubtractedFromThisCategory = timeToSubtractForCategory(categoryId) val realRemainingTimeDefault = (remainingTimeFromCache.default - timeSubtractedFromThisCategory).coerceAtLeast(0) val realRemainingTimeWithExtraTime = (remainingTimeFromCache.includingExtraTime - timeSubtractedFromThisCategory).coerceAtLeast(0) val realRemainingTimeUsingExtraTime = realRemainingTimeDefault == 0L && realRemainingTimeWithExtraTime > 0 val remainingSessionDuration = handling.remainingSessionDuration?.let { (it - timeToSubtractForCategory(categoryId)).coerceAtLeast(0) } buildStatusMessageWithCurrentAppTitle( text = if (realRemainingTimeUsingExtraTime) appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(realRemainingTimeWithExtraTime.toInt(), appLogic.context)) else if (remainingSessionDuration != null && remainingSessionDuration < realRemainingTimeDefault) TimeTextUtil.pauseIn(remainingSessionDuration.toInt(), appLogic.context) else TimeTextUtil.remaining(realRemainingTimeDefault.toInt() ?: 0, appLogic.context), titlePrefix = titlePrefix, titleSuffix = suffix, appPackageName = appPackageName, appActivityToShow = appActivityToShow ) } } fun buildNotificationForAppWithoutCategoryUsage( handling: AppBaseHandling, suffix: String, appPackageName: String?, appActivityToShow: String? ): AppStatusMessage = when (handling) { is AppBaseHandling.UseCategories -> throw IllegalArgumentException() AppBaseHandling.BlockDueToNoCategory -> throw IllegalArgumentException() AppBaseHandling.PauseLogic -> AppStatusMessage( title = appLogic.context.getString(R.string.background_logic_paused_title) + suffix, text = appLogic.context.getString(R.string.background_logic_paused_text), showSwitchToDefaultUserOption = deviceRelatedData.canSwitchToDefaultUser ) is AppBaseHandling.Whitelist -> buildStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_whitelisted), titleSuffix = suffix, appPackageName = appPackageName, appActivityToShow = appActivityToShow ) AppBaseHandling.TemporarilyAllowed -> buildStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_temporarily_allowed), titleSuffix = suffix, appPackageName = appPackageName, appActivityToShow = appActivityToShow ) AppBaseHandling.Idle -> AppStatusMessage( appLogic.context.getString(R.string.background_logic_idle_title) + suffix, appLogic.context.getString(R.string.background_logic_idle_text), showSwitchToDefaultUserOption = deviceRelatedData.canSwitchToDefaultUser ) is AppBaseHandling.SanctionCountEverything -> AppStatusMessage( title = appLogic.context.getString(R.string.background_logic_permission_sanction_title) + suffix, text = appLogic.context.getString(R.string.background_logic_permission_sanction_text), showSwitchToDefaultUserOption = deviceRelatedData.canSwitchToDefaultUser ) } val showBackgroundStatus = !(backgroundAppBaseHandling is AppBaseHandling.Idle) && !blockAudioPlayback && foregroundAppWithBaseHandlings.find { (app, handling) -> app.packageName == audioPlaybackPackageName && handling.getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) == backgroundAppBaseHandling.getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) } == null val statusMessage = if (blockedForegroundApp != null) { buildStatusMessageWithCurrentAppTitle( text = appLogic.context.getString(R.string.background_logic_opening_lockscreen), appPackageName = blockedForegroundApp.packageName, appActivityToShow = if (activityLevelBlocking) blockedForegroundApp.activityName else null ) } else { val pagesForTheForegroundApps = foregroundAppWithBaseHandlings.sumOf { (_, foregroundAppBaseHandling) -> foregroundAppBaseHandling .getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) .count() .coerceAtLeast(1) } val pagesForTheBackgroundApp = if (showBackgroundStatus) { backgroundAppBaseHandling.getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) .count() .coerceAtLeast(1) } else 0 val totalPages = pagesForTheForegroundApps.coerceAtLeast(1) + pagesForTheBackgroundApp val currentPage = (nowTimestamp / 3000 % totalPages).toInt() val suffix = if (totalPages == 1) "" else " (${currentPage + 1} / $totalPages)" if (currentPage < pagesForTheForegroundApps.coerceAtLeast(1)) { if (pagesForTheForegroundApps == 0) { buildNotificationForAppWithoutCategoryUsage( appPackageName = null, appActivityToShow = null, suffix = suffix, handling = AppBaseHandling.Idle ) } else { val pageWithin = currentPage var listItemIndex = 0 var indexWithinListItem = 0 var totalIndex = 0 while (listItemIndex < foregroundAppWithBaseHandlings.size) { val item = foregroundAppWithBaseHandlings[listItemIndex] val handling = item.second val itemLength = handling .getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) .count() .coerceAtLeast(1) if (pageWithin < totalIndex + itemLength) { indexWithinListItem = pageWithin - totalIndex break } totalIndex += itemLength listItemIndex++ } val (app, handling) = foregroundAppWithBaseHandlings[listItemIndex] val categoryId = handling .getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) .elementAtOrNull(indexWithinListItem) if (categoryId != null) { buildNotificationForAppWithCategoryUsage( appPackageName = app.packageName, appActivityToShow = if (activityLevelBlocking) app.activityName else null, suffix = suffix, categoryId = categoryId ) } else { buildNotificationForAppWithoutCategoryUsage( appPackageName = app.packageName, appActivityToShow = if (activityLevelBlocking) app.activityName else null, suffix = suffix, handling = handling ) } } } else { val pageWithin = currentPage - pagesForTheForegroundApps val categoryId = backgroundAppBaseHandling .getCategories(AppBaseHandling.GetCategoriesPurpose.ShowingInStatusNotification) .asIterable() .elementAtOrNull(pageWithin) if (categoryId != null) { buildNotificationForAppWithCategoryUsage( appPackageName = audioPlaybackPackageName, appActivityToShow = if (activityLevelBlocking) DummyApps.ACTIVITY_BACKGROUND_AUDIO else null, suffix = suffix, categoryId = categoryId ) } else { buildNotificationForAppWithoutCategoryUsage( appPackageName = audioPlaybackPackageName, appActivityToShow = if (activityLevelBlocking) DummyApps.ACTIVITY_BACKGROUND_AUDIO else null, suffix = suffix, handling = backgroundAppBaseHandling ) } } } appLogic.platformIntegration.setAppStatusMessage(statusMessage) // handle blocking if (blockedForegroundApp != null) { openLockscreen( blockedAppPackageName = blockedForegroundApp.packageName, blockedAppActivityName = blockedForegroundApp.activityName, enableSoftBlocking = deviceRelatedData.experimentalFlags and ExperimentalFlags.ENABLE_SOFT_BLOCKING == ExperimentalFlags.ENABLE_SOFT_BLOCKING ) } else { appLogic.platformIntegration.setShowBlockingOverlay(false) } if (blockAudioPlayback && audioPlaybackPackageName != null) { val currentAudioBlockUptime = appLogic.timeApi.getCurrentUptimeInMillis() val oldAudioPlaybackBlock = previousAudioPlaybackBlock val skipAudioBlock = oldAudioPlaybackBlock != null && oldAudioPlaybackBlock.second == audioPlaybackPackageName && oldAudioPlaybackBlock.first >= currentAudioBlockUptime if (!skipAudioBlock) { val newAudioPlaybackBlock = currentAudioBlockUptime + 1000 * 10 /* block for 10 seconds */ to audioPlaybackPackageName previousAudioPlaybackBlock = newAudioPlaybackBlock runAsync { if (appLogic.platformIntegration.muteAudioIfPossible(audioPlaybackPackageName)) { appLogic.platformIntegration.showOverlayMessage( appLogic.context.getString( R.string.background_logic_toast_block_audio, appTitleCache.query(audioPlaybackPackageName) ) ) // allow blocking again // no locking needed because everything happens on the main thread if (previousAudioPlaybackBlock === newAudioPlaybackBlock) { previousAudioPlaybackBlock = appLogic.timeApi.getCurrentUptimeInMillis() + 1000 * 1 /* block for 1 more second */ to audioPlaybackPackageName } } else { appLogic.platformIntegration.showOverlayMessage(appLogic.context.getString(R.string.background_logic_toast_block_audio_failed)) } } } } } catch (ex: SecurityException) { // this is handled by an other main loop (with a delay) lastLoopException.postValue(ex) appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_permission), showSwitchToDefaultUserOption = deviceRelatedData.canSwitchToDefaultUser )) appLogic.platformIntegration.setShowBlockingOverlay(false) } catch (ex: Exception) { if (BuildConfig.DEBUG) { Log.w(LOG_TAG, "exception during running main loop", ex) } lastLoopException.postValue(ex) appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_internal), showSwitchToDefaultUserOption = deviceRelatedData.canSwitchToDefaultUser )) appLogic.platformIntegration.setShowBlockingOverlay(false) } // delay before running next time val endTime = appLogic.timeApi.getCurrentUptimeInMillis() previousMainLogicExecutionTime = (endTime - previousMainLoopEndTime).toInt() previousMainLoopEndTime = endTime val timeToWait = Math.max(10, backgroundServiceInterval - 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 { syncDeviceStatusFast() } } private suspend fun syncDeviceStatusLoop() { while (true) { appLogic.deviceEntryIfEnabled.waitUntilValueMatches { it != null } syncDeviceStatusSlow() appLogic.timeApi.sleep(CHECK_PERMISSION_INTERVAL) } } private suspend fun checkForceKilled() { appLogic.platformIntegration.getExitLog(1).singleOrNull()?.let { item -> if ( item.reason == ExitReason.UserRequest && item.description != null && item.description.startsWith("fully stop ") && item.description.endsWith("by user request") ) { appLogic.isInitialized.waitUntilValueMatches { it == true } try { ApplyActionUtil.applyAppLogicAction( action = UpdateDeviceStatusAction.empty.copy( addedManipulationFlags = ManipulationFlag.USED_FGS_KILLER ), appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } catch (ex: Exception) { if (BuildConfig.DEBUG) { Log.w(LOG_TAG, "could not save a forced kill notification", ex) } } } } } 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 getUpdateDeviceStatusAction(): UpdateDeviceStatusAction { val deviceEntry = appLogic.deviceEntry.waitForNullableValue() val useStrictChecking = appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.STRICT_OVERLAY_CHECKING).waitForNonNullValue() var changes = UpdateDeviceStatusAction.empty if (deviceEntry != null) { val protectionLevel = appLogic.platformIntegration.getCurrentProtectionLevel() val usageStatsPermission = appLogic.platformIntegration.getForegroundAppPermissionStatus() val notificationAccess = appLogic.platformIntegration.getNotificationAccessPermissionStatus() val overlayPermission = appLogic.platformIntegration.getDrawOverOtherAppsPermissionStatus(useStrictChecking) val accessibilityService = appLogic.platformIntegration.isAccessibilityServiceEnabled() val qOrLater = AndroidVersion.qOrLater val platformType = DevicePlatform.ANDROID val platformLevel = AndroidVersion.platformLevel 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 (deviceEntry.platformType != platformType) { changes = changes.copy(newPlatformType = platformType) } if (deviceEntry.platformLevel != platformLevel) { changes = changes.copy(newPlatformLevel = platformLevel) } } return changes } private suspend fun syncDeviceStatusFast() { syncDeviceStatusLock.withLock { val changes = getUpdateDeviceStatusAction() if (changes != UpdateDeviceStatusAction.empty) { ApplyActionUtil.applyAppLogicAction( action = changes, appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } } } private suspend fun syncDeviceStatusSlow() { syncDeviceStatusLock.withLock { val changesOne = getUpdateDeviceStatusAction() delay(2000) val changesTwo = getUpdateDeviceStatusAction() if ( changesOne != UpdateDeviceStatusAction.empty && changesOne == changesTwo ) { ApplyActionUtil.applyAppLogicAction( action = changesOne, appLogic = appLogic, ignoreIfDeviceIsNotConfigured = true ) } } } suspend fun resetTemporarilyAllowedApps() { val deviceId = appLogic.deviceId.waitForNullableValue() if (deviceId != null) { Threads.database.executeAndWait(Runnable { appLogic.database.temporarilyAllowedApp().removeAllTemporarilyAllowedAppsSync(deviceId) }) } } 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 */) } } private suspend fun annoyUserOnManipulationLoop() { val shouldAnnoyNow = appLogic.annoyLogic.shouldAnnoyRightNow while (true) { shouldAnnoyNow.waitUntilValueMatches { it == true } appLogic.platformIntegration.showAnnoyScreen() // bring into foreground after some time withTimeoutOrNull(300 * 1000L) { shouldAnnoyNow.waitUntilValueMatches { it == false } } } } }