diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a1391d3d8..895829ad7 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -53,7 +53,6 @@ kotlin { // This makes instrumented tests depend on commonTest and androidUnitTest sources sourceSetTree.set(KotlinSourceSetTree.test) } - instrumentedTestVariant { } } iosX64() diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt index cf1075fff..62b319a9d 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt @@ -46,7 +46,7 @@ class OnboardingTest { clickOnText("True") wait { onNodeWithText("Automated testing").isDisplayed() } - clickOnTag("Yes-AutoTest") + clickOnTag("No-AutoTest") wait { onNodeWithText("Crash Reporting").isDisplayed() } clickOnTag("Yes-CrashReporting") @@ -63,7 +63,7 @@ class OnboardingTest { } assertEquals( - true, + false, preferences.getValueByKey(SettingsKey.AUTOMATED_TESTING_ENABLED).first(), ) assertEquals(true, preferences.getValueByKey(SettingsKey.SEND_CRASH).first()) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 8a2afc484..1a6034df9 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -6,6 +6,7 @@ + = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -65,6 +70,7 @@ class AndroidApplication : Application() { LocaleList.forLanguageTags(getString(R.string.supported_languages)), ) } + registerActivityLifecycleCallbacks(mainActivityLifecycleCallbacks) } private val platformInfo by lazy { @@ -194,4 +200,14 @@ class AndroidApplication : Application() { false } } + + private val batteryOptimization by lazy { + AndroidBatteryOptimization( + powerManager = getSystemService(PowerManager::class.java), + packageName = packageName, + requestCall = { callback -> + mainActivityLifecycleCallbacks.activity?.requestIgnoreBatteryOptimization(callback) + }, + ) + } } diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivity.kt index 15f572b54..157eee80e 100644 --- a/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivity.kt @@ -1,10 +1,15 @@ package org.ooni.probe +import android.annotation.SuppressLint +import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContract import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -60,4 +65,28 @@ class MainActivity : ComponentActivity() { } } } + + // Battery Optimization + + private var ignoreBatteryOptimizationCallback: (() -> Unit)? = null + + private val ignoreBatteryOptimizationContract = + registerForActivityResult(object : ActivityResultContract() { + @SuppressLint("BatteryLife") + override fun createIntent( + context: Context, + input: Unit, + ) = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + .setData(Uri.parse("package:$packageName")) + + override fun parseResult( + resultCode: Int, + intent: Intent?, + ) {} + }) { ignoreBatteryOptimizationCallback?.invoke() } + + fun requestIgnoreBatteryOptimization(callback: () -> Unit) { + ignoreBatteryOptimizationCallback = callback + ignoreBatteryOptimizationContract.launch(Unit) + } } diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivityLifecycleCallbacks.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivityLifecycleCallbacks.kt new file mode 100644 index 000000000..0a42c6701 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivityLifecycleCallbacks.kt @@ -0,0 +1,34 @@ +package org.ooni.probe + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle + +class MainActivityLifecycleCallbacks : ActivityLifecycleCallbacks { + var activity: MainActivity? = null + private set + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) { + this.activity = activity as? MainActivity + } + + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityDestroyed(activity: Activity) { + this.activity = null + } +} diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/config/AndroidBatteryOptimization.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/config/AndroidBatteryOptimization.kt new file mode 100644 index 000000000..592196cc8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/config/AndroidBatteryOptimization.kt @@ -0,0 +1,20 @@ +package org.ooni.probe.config + +import android.os.PowerManager + +class AndroidBatteryOptimization( + private val powerManager: PowerManager, + private val packageName: String, + private val requestCall: (() -> Unit) -> Unit, +) : BatteryOptimization { + override val isSupported = true + + override val isIgnoring: Boolean + get() = powerManager.isIgnoringBatteryOptimizations(packageName) + + override fun requestIgnore(onResponse: (Boolean) -> Unit) { + requestCall { + onResponse(isIgnoring) + } + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 91f3c117d..7f4351d81 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -224,6 +224,8 @@ Are you sure? Your URLs will not be saved when you leave this screen. Are you sure you want to leave this screen? + OONI Probe cannot run automatically without battery optimization. Do you want to try again? + Back refresh diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/config/BatteryOptimization.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/BatteryOptimization.kt new file mode 100644 index 000000000..b39696258 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/BatteryOptimization.kt @@ -0,0 +1,12 @@ +package org.ooni.probe.config + +interface BatteryOptimization { + val isSupported: Boolean get() = false + + val isIgnoring: Boolean + get() = throw IllegalStateException("Battery Optimization not supported") + + fun requestIgnore(onResponse: (Boolean) -> Unit) { + throw IllegalStateException("Battery Optimization not supported") + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt index 3a1c5fd11..a86ec9036 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -22,6 +22,7 @@ import org.ooni.engine.NetworkTypeFinder import org.ooni.engine.OonimkallBridge import org.ooni.engine.TaskEventMapper import org.ooni.probe.Database +import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.data.disk.DeleteFiles import org.ooni.probe.data.disk.DeleteFilesOkio import org.ooni.probe.data.disk.ReadFile @@ -111,6 +112,7 @@ class Dependencies( val fetchDescriptorUpdate: suspend (List?) -> Unit, val localeDirection: (() -> LayoutDirection)? = null, private val shareFile: (FileSharing) -> Boolean, + private val batteryOptimization: BatteryOptimization, ) { // Common @@ -426,6 +428,7 @@ class Dependencies( platformInfo = platformInfo, preferenceRepository = preferenceRepository, launchUrl = { launchUrl(it, null) }, + batteryOptimization = batteryOptimization, ) fun proxyViewModel(onBack: () -> Unit) = ProxyViewModel(onBack, preferenceRepository) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingScreen.kt index d23844d42..d282a9947 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars @@ -20,11 +19,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LocalContentColor @@ -34,7 +32,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,6 +45,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import co.touchlab.kermit.Logger import dev.icerock.moko.permissions.DeniedAlwaysException import dev.icerock.moko.permissions.DeniedException @@ -58,8 +56,11 @@ import dev.icerock.moko.permissions.compose.PermissionsControllerFactory import dev.icerock.moko.permissions.compose.rememberPermissionsControllerFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import ooniprobe.composeapp.generated.resources.Modal_Autorun_BatteryOptimization +import ooniprobe.composeapp.generated.resources.Modal_Cancel import ooniprobe.composeapp.generated.resources.Modal_EnableNotifications_Paragraph import ooniprobe.composeapp.generated.resources.Modal_EnableNotifications_Title +import ooniprobe.composeapp.generated.resources.Modal_OK import ooniprobe.composeapp.generated.resources.Onboarding_AutomatedTesting_Paragraph import ooniprobe.composeapp.generated.resources.Onboarding_AutomatedTesting_Title import ooniprobe.composeapp.generated.resources.Onboarding_Crash_Button_No @@ -97,54 +98,44 @@ fun OnboardingScreen( state: OnboardingViewModel.State, onEvent: (OnboardingViewModel.Event) -> Unit, ) { - val pagerState = rememberPagerState(0, pageCount = { state.stepList.size }) - LaunchedEffect(state.stepIndex) { - pagerState.animateScrollToPage(state.stepIndex) - } var showQuiz by remember { mutableStateOf(false) } Box { - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - modifier = Modifier.fillMaxSize(), - ) { stepIndex -> - val step = state.stepList[state.stepIndex] - Surface( - color = step.surfaceColor, - contentColor = LocalCustomColors.current.onOnboarding, + Surface( + color = state.step.surfaceColor, + contentColor = LocalCustomColors.current.onOnboarding, + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .padding(WindowInsets.navigationBars.asPaddingValues()) + .padding(bottom = 48.dp), ) { - Column( - modifier = Modifier - .fillMaxHeight() - .padding(WindowInsets.navigationBars.asPaddingValues()) - .padding(bottom = 48.dp), - ) { - when (state.stepList[stepIndex]) { - OnboardingViewModel.Step.WhatIs -> - WhatIsStep(onEvent) - - OnboardingViewModel.Step.HeadsUp -> - HeadsUpStep( - onEvent = onEvent, - onShowQuiz = { showQuiz = true }, - ) - - OnboardingViewModel.Step.AutomatedTesting -> - AutomatedTestingStep(onEvent) - - OnboardingViewModel.Step.CrashReporting -> - CrashReportingStep(onEvent) - - OnboardingViewModel.Step.RequestNotificationPermission -> - RequestPermissionStep(onEvent) - - OnboardingViewModel.Step.DefaultSettings -> - DefaultSettingsStep(onEvent) - } + when (state.step) { + OnboardingViewModel.Step.WhatIs -> + WhatIsStep(onEvent) + + OnboardingViewModel.Step.HeadsUp -> + HeadsUpStep( + onEvent = onEvent, + onShowQuiz = { showQuiz = true }, + ) + + is OnboardingViewModel.Step.AutomatedTesting -> + AutomatedTestingStep(state.step.showBatteryOptimizationDialog, onEvent) + + OnboardingViewModel.Step.CrashReporting -> + CrashReportingStep(onEvent) + + OnboardingViewModel.Step.RequestNotificationPermission -> + RequestPermissionStep(onEvent) + + OnboardingViewModel.Step.DefaultSettings -> + DefaultSettingsStep(onEvent) } } } + Row( Modifier .wrapContentHeight() @@ -153,11 +144,11 @@ fun OnboardingScreen( .padding(WindowInsets.navigationBars.asPaddingValues()), verticalAlignment = Alignment.CenterVertically, ) { - repeat(pagerState.pageCount) { index -> + repeat(state.totalSteps) { index -> if (index != 0) { Box( modifier = Modifier - .alpha(if (pagerState.currentPage >= index) 1f else 0.33f) + .alpha(if (state.stepIndex >= index) 1f else 0.33f) .background(LocalCustomColors.current.onOnboarding) .height(2.dp) .width(36.dp), @@ -165,7 +156,7 @@ fun OnboardingScreen( } Box( modifier = Modifier - .alpha(if (pagerState.currentPage >= index) 1f else 0.33f) + .alpha(if (state.stepIndex >= index) 1f else 0.33f) .padding(1.dp) .clip(CircleShape) .background(LocalCustomColors.current.onOnboarding) @@ -245,7 +236,10 @@ fun ColumnScope.HeadsUpStep( } @Composable -fun ColumnScope.AutomatedTestingStep(onEvent: (OnboardingViewModel.Event) -> Unit) { +fun ColumnScope.AutomatedTestingStep( + showBatteryOptimizationDialog: Boolean, + onEvent: (OnboardingViewModel.Event) -> Unit, +) { OnboardingImage(OrganizationConfig.onboardingImages.image3) OnboardingTitle(Res.string.Onboarding_AutomatedTesting_Title) Column( @@ -263,15 +257,37 @@ fun ColumnScope.AutomatedTestingStep(onEvent: (OnboardingViewModel.Event) -> Uni onClick = { onEvent(OnboardingViewModel.Event.AutoTestNoClicked) }, modifier = Modifier .padding(horizontal = 8.dp) - .weight(1f), + .weight(1f) + .testTag("No-AutoTest"), ) OnboardingMainButton( text = Res.string.Onboarding_Crash_Button_Yes, onClick = { onEvent(OnboardingViewModel.Event.AutoTestYesClicked) }, modifier = Modifier .padding(horizontal = 8.dp) - .weight(1f) - .testTag("Yes-AutoTest"), + .weight(1f), + ) + } + + if (showBatteryOptimizationDialog) { + AlertDialog( + onDismissRequest = { }, + text = { Text(stringResource(Res.string.Modal_Autorun_BatteryOptimization)) }, + confirmButton = { + TextButton(onClick = { + onEvent(OnboardingViewModel.Event.BatteryOptimizationOkClicked) + }) { + Text(stringResource(Res.string.Modal_OK)) + } + }, + dismissButton = { + TextButton(onClick = { + onEvent(OnboardingViewModel.Event.BatteryOptimizationCancelClicked) + }) { + Text(stringResource(Res.string.Modal_Cancel)) + } + }, + properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false), ) } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingViewModel.kt index b04c06458..46ce4b38d 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/onboarding/OnboardingViewModel.kt @@ -9,10 +9,12 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.data.repositories.PreferenceRepository import org.ooni.probe.shared.PlatformInfo -import org.ooni.probe.ui.dashboard.DashboardViewModel.Event class OnboardingViewModel( private val goToDashboard: () -> Unit, @@ -20,16 +22,28 @@ class OnboardingViewModel( platformInfo: PlatformInfo, private val preferenceRepository: PreferenceRepository, private val launchUrl: (String) -> Unit, + private val batteryOptimization: BatteryOptimization, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) + private val stepList = listOfNotNull( + Step.WhatIs, + Step.HeadsUp, + Step.AutomatedTesting(false), + Step.CrashReporting, + if (platformInfo.needsToRequestNotificationsPermission) { + Step.RequestNotificationPermission + } else { + null + }, + Step.DefaultSettings, + ) + private val _state = MutableStateFlow( State( - stepList = if (platformInfo.needsToRequestNotificationsPermission) { - Step.entries - } else { - Step.entries - Step.RequestNotificationPermission - }, + step = stepList[0], + stepIndex = 0, + totalSteps = stepList.size, ), ) val state = _state.asStateFlow() @@ -50,14 +64,34 @@ class OnboardingViewModel( events.filterIsInstance(), ) .onEach { event -> + val enableAutoTest = event == Event.AutoTestYesClicked + preferenceRepository.setValueByKey( SettingsKey.AUTOMATED_TESTING_ENABLED, - event == Event.AutoTestYesClicked, + enableAutoTest, ) - moveToNextStep() + + if (enableAutoTest && + batteryOptimization.isSupported && + !batteryOptimization.isIgnoring + ) { + requestIgnoreBatteryOptimization() + } else { + moveToNextStep() + } } .launchIn(viewModelScope) + events + .filterIsInstance() + .onEach { requestIgnoreBatteryOptimization() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { moveToNextStep() } + .launchIn(viewModelScope) + merge( events.filterIsInstance(), events.filterIsInstance(), @@ -94,26 +128,48 @@ class OnboardingViewModel( private suspend fun moveToNextStep() { val state = _state.value - if (state.stepIndex < state.stepList.size - 1) { - _state.value = state.copy(stepIndex = state.stepIndex + 1) + if (state.stepIndex < stepList.size - 1) { + val newIndex = state.stepIndex + 1 + _state.value = state.copy( + step = stepList[newIndex], + stepIndex = newIndex, + ) } else { preferenceRepository.setValueByKey(SettingsKey.FIRST_RUN, false) goToDashboard() } } + private fun requestIgnoreBatteryOptimization() { + batteryOptimization.requestIgnore { isIgnoring -> + if (isIgnoring) { + viewModelScope.launch { moveToNextStep() } + } else { + _state.update { + it.copy(step = Step.AutomatedTesting(true)) + } + } + } + } + data class State( - val stepIndex: Int = 0, - val stepList: List, + val step: Step, + val stepIndex: Int, + val totalSteps: Int, ) - enum class Step { - WhatIs, - HeadsUp, - AutomatedTesting, - CrashReporting, - RequestNotificationPermission, - DefaultSettings, + sealed interface Step { + data object WhatIs : Step + + data object HeadsUp : Step + + data class AutomatedTesting(val showBatteryOptimizationDialog: Boolean) : Step + + data object CrashReporting : Step + + data object RequestNotificationPermission : Step + + data object DefaultSettings : Step } sealed interface Event { @@ -125,6 +181,10 @@ class OnboardingViewModel( data object AutoTestNoClicked : Event + data object BatteryOptimizationOkClicked : Event + + data object BatteryOptimizationCancelClicked : Event + data object CrashReportingYesClicked : Event data object CrashReportingNoClicked : Event diff --git a/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt b/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt index a1ee59363..48273d9e9 100644 --- a/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt +++ b/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.launch import org.ooni.engine.NetworkTypeFinder import org.ooni.engine.OonimkallBridge import org.ooni.probe.background.OperationsManager +import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.config.OrganizationConfig import org.ooni.probe.data.models.AutoRunParameters import org.ooni.probe.data.models.DeepLink @@ -77,6 +78,7 @@ class SetupDependencies( fetchDescriptorUpdate = ::fetchDescriptorUpdate, localeDirection = ::localeDirection, shareFile = ::shareFile, + batteryOptimization = object : BatteryOptimization {}, ) private val operationsManager = OperationsManager(dependencies)