From 44ec9a2bd3d20b872ad5110f8bcfceca47ea3f80 Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:20:10 +0000 Subject: [PATCH 1/8] Remove unused Android permission paths and legacy resources --- androidApp/src/main/AndroidManifest.xml | 9 -- .../mathalarm/AlarmReceiver.kt | 1 - .../mathalarm/framework/app/di/AppModule.kt | 4 - .../app/permission/AlarmPermission.kt | 9 -- .../app/permission/ScreenNavigator.kt | 17 ---- .../app/permission/ScreenNavigatorImpl.kt | 41 --------- .../AlarmNotificationScheduler.kt | 7 -- .../mathalarm/platform/PlatformApis.kt | 16 ++-- .../mathalarm/utils/ContextExtensions.kt | 27 ++---- .../mathalarm/utils/Permissions.kt | 73 --------------- .../mathalarm/utils/TestTags.kt | 6 -- androidApp/src/main/res/values/strings.xml | 88 ------------------- .../mathalarm/fake/ScreenNavigatorFake.kt | 26 ------ .../app/permission/AlarmPermissionTest.kt | 21 +---- .../platform/PlatformApis.android.kt | 8 +- .../app/permission/AlarmPermission.kt | 14 +-- .../mathalarm/fake/AlarmPermissionFake.kt | 18 ---- .../app/permission/AlarmPermissionImpl.kt | 20 ----- 18 files changed, 16 insertions(+), 389 deletions(-) delete mode 100644 androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt delete mode 100644 androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt delete mode 100644 androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/Permissions.kt delete mode 100644 androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/TestTags.kt delete mode 100644 androidApp/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 27bf0199..d703df7a 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -57,15 +57,6 @@ android:name=".notification.AlarmService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> - - - - - - - - - \ No newline at end of file diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt index aa10d099..6d0822a5 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt @@ -110,7 +110,6 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent { const val EXTRA_TASK = "extra_task" - const val EXTRA_PENDING_INTENT_ID = "extra_pending_intent_id" const val ALARM_ACTION = "com.timilehinaregbesola.mathalarm.SET_ALARM" diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt index ebc0d15e..e6cad0b2 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt @@ -20,8 +20,6 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AndroidVersion import com.timilehinaregbesola.mathalarm.framework.app.permission.AndroidVersionImpl import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionChecker import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionCheckerImpl -import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigator -import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigatorImpl import com.timilehinaregbesola.mathalarm.framework.database.AlarmDatabase import com.timilehinaregbesola.mathalarm.framework.database.MIGRATION_2_3 import com.timilehinaregbesola.mathalarm.framework.database.MIGRATION_3_4 @@ -118,12 +116,10 @@ val androidModule = module { // Permission abstractions single { AndroidVersionImpl() } - single { ScreenNavigatorImpl(androidContext()) } single { PermissionCheckerImpl(androidContext().getAlarmManager()) } single { AlarmPermissionImpl( - screenNavigator = get(), permissionChecker = get(), androidVersion = get() ) diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt index 82d65cf2..94b0084c 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt @@ -6,7 +6,6 @@ import android.os.Build * Android implementation of [AlarmPermission] using abstracted dependencies. */ class AlarmPermissionImpl( - private val screenNavigator: ScreenNavigator, private val permissionChecker: PermissionChecker, private val androidVersion: AndroidVersion ) : AlarmPermission { @@ -23,12 +22,4 @@ class AlarmPermissionImpl( true } } - - override fun openExactAlarmPermissionScreen() { - screenNavigator.openExactAlarmPermissionScreen() - } - - override fun openAppSettings() { - screenNavigator.openAppSettings() - } } diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt deleted file mode 100644 index a272209a..00000000 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.timilehinaregbesola.mathalarm.framework.app.permission - -/** - * Abstraction for navigating to system screens. - * Extracted for better testability. - */ -interface ScreenNavigator { - /** - * Opens the exact alarm permission screen (Android S+). - */ - fun openExactAlarmPermissionScreen() - - /** - * Opens the app settings screen. - */ - fun openAppSettings() -} diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt deleted file mode 100644 index 945b3628..00000000 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.timilehinaregbesola.mathalarm.framework.app.permission - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.core.net.toUri - -/** - * Android implementation of [ScreenNavigator]. - */ -class ScreenNavigatorImpl( - private val context: Context -) : ScreenNavigator { - - override fun openExactAlarmPermissionScreen() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val intent = Intent( - Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, - "package:${context.packageName}".toUri() - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } else { - // On older versions, open app settings instead - openAppSettings() - } - } - - override fun openAppSettings() { - val intent = Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - "package:${context.packageName}".toUri() - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } -} diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt index a0321cd9..09755c15 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt @@ -46,13 +46,6 @@ class AlarmNotificationScheduler( } } - @OptIn( - androidx.compose.animation.ExperimentalAnimationApi::class, - androidx.compose.foundation.ExperimentalFoundationApi::class, - androidx.compose.material3.ExperimentalMaterial3Api::class, - androidx.compose.ui.ExperimentalComposeUiApi::class, - kotlinx.coroutines.InternalCoroutinesApi::class - ) fun updateAlarm(alarm: Alarm) { AlarmService.updateAlarm(context, alarm) logger.d("Updated playback metadata for alarm ${alarm.alarmId}") diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt index 62a30f18..5b4bd2b2 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt @@ -58,18 +58,12 @@ fun getRingtoneTitle(alarmTone: String): String { fun openNotificationSettings() { val context: Context = getKoinContext() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - trackPermissionHandoff("notifications", PermissionAnalyticsPending.NOTIFICATIONS) - } else { - val intent = Intent(Settings.ACTION_SETTINGS).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - context.startActivity(intent) - trackPermissionHandoff("notifications", PermissionAnalyticsPending.NOTIFICATIONS) + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(intent) + trackPermissionHandoff("notifications", PermissionAnalyticsPending.NOTIFICATIONS) } fun requestExactAlarmPermission() { diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt index 050cbcc6..77157edd 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt @@ -2,18 +2,12 @@ package com.timilehinaregbesola.mathalarm.utils -import android.app.Activity import android.app.AlarmManager import android.app.PendingIntent import android.content.Context -import android.content.pm.PackageManager import android.os.Build -import androidx.core.app.ActivityCompat import androidx.core.app.AlarmManagerCompat -import androidx.core.content.ContextCompat import co.touchlab.kermit.Logger -import kotlin.time.Clock -import kotlin.time.ExperimentalTime /** * Sets a alarm using [AlarmManagerCompat] to be triggered based on the given parameter. @@ -23,9 +17,13 @@ import kotlin.time.ExperimentalTime * @param operation action to perform when the alarm goes off * @param type type to define how the alarm will behave */ -@OptIn(ExperimentalTime::class, androidx.compose.foundation.ExperimentalFoundationApi::class, - androidx.compose.material3.ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, - kotlinx.coroutines.InternalCoroutinesApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) +@OptIn( + androidx.compose.foundation.ExperimentalFoundationApi::class, + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.ui.ExperimentalComposeUiApi::class, + kotlinx.coroutines.InternalCoroutinesApi::class, + androidx.compose.animation.ExperimentalAnimationApi::class, +) fun Context.setExactAlarm( triggerAtMillis: Long, operation: PendingIntent?, @@ -74,14 +72,3 @@ fun Context.cancelAlarm(operation: PendingIntent?) { Logger.e(e, tag = "Context cancelAlarm") {"Failed to cancel alarm"} } } - -fun Context.hasPermission(permission: String) = - ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED - -fun Activity.handlePermission(permission: String, callback: (granted: Boolean) -> Unit) { - if (hasPermission(permission)) { - callback(true) - } else { - ActivityCompat.requestPermissions(this, arrayOf(permission), 3) - } -} diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/Permissions.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/Permissions.kt deleted file mode 100644 index 63bb1c76..00000000 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/Permissions.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.timilehinaregbesola.mathalarm.utils - -import android.Manifest -import android.Manifest.permission.READ_EXTERNAL_STORAGE -import android.app.Activity -import android.app.AlertDialog -import android.content.Context -import android.content.pm.PackageManager -import android.media.MediaPlayer -import android.media.RingtoneManager -import android.net.Uri -import android.os.Build -import androidx.core.net.toUri -import co.touchlab.kermit.Logger - -/** - * Checks if all ringtones can be played, and requests permissions if it is not the case - */ -fun checkPermissions( - activity: Activity, - tones: List, - unplayableDialogTitle: String, - unplayableDialogMessage: (String) -> String -) { - if (Build.VERSION.SDK_INT >= 23 && activity.checkSelfPermission(READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - val unplayable = tones - .filter { alarmtone -> - runCatching { - val player = MediaPlayer() - player.setDataSource(activity, alarmtone.toUri()) - player.apply { - setOnErrorListener { mp, _, _ -> - Logger.e("Error occured while playing audio.") - mp.stop() - mp.release() - true - } - } - }.isFailure - } - .mapNotNull { tone -> RingtoneManager.getRingtone(activity, Uri.parse(tone)) } - .map { ringtone -> - runCatching { - ringtone.getTitle(activity) ?: "null" - }.getOrDefault("null") - } - - if (unplayable.isNotEmpty()) { - try { - AlertDialog.Builder(activity).setTitle(unplayableDialogTitle) - .setMessage(unplayableDialogMessage(unplayable.joinToString(", "))) - .setPositiveButton(android.R.string.ok) { _, _ -> - activity.requestPermissions(arrayOf(READ_EXTERNAL_STORAGE), 3) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } catch (e: Exception) { - Logger.e("Was not able to show dialog to request permission, continue without the dialog") - activity.requestPermissions(arrayOf(READ_EXTERNAL_STORAGE), 3) - } - } - } -} - -fun handleNotificationPermission(context: Activity, callback: (granted: Boolean) -> Unit) { - if (Build.VERSION.SDK_INT >= 33) { - context.handlePermission(Manifest.permission.POST_NOTIFICATIONS) { granted -> - callback(granted) - } - } else { - callback(true) - } -} diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/TestTags.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/TestTags.kt deleted file mode 100644 index 0c1e3ba7..00000000 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/TestTags.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.timilehinaregbesola.mathalarm.utils - -object TestTags { - const val SETTINGS_DROPDOWN = "SETTINGS_DROPDOWN" - const val ALARM_ITEM = "ALARM_ITEM" -} diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index cc59b2b0..dbb317e5 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -1,96 +1,8 @@ - Set Alarm Time: - - Set Alarm Time - - Set On - - Repeat - Repeat Weekly - Alarm Settings - Vibrate - Alarm Tone - Snooze (0 = OFF) - minute(s) - Math Difficulty - Test Alarm - - Add Alarm - Set - Delete - - MON - TUE - WED - THU - FRI - SAT - SUN - - 0 - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - DEL - CLEAR - DISMISS - SNOOZE - - No snooze allowed - - Empty! - \nTap to\n add an\n alarm - - Android - Speech Bubble - Message - - Duplicate alarm detected - Alarm set for - from now. - minute - minutes - hour, - hours, - day, - days, - Alarm tone not available. - OK - Clear Alarms - alarm_channel Math Alarm - Math Alarm!! - No ringtone picker - Alarm Tone (Default) - Ringtone - It seems that we cannot play %s, probably because a permission is required. If you want, you can grant the permission. Alternatively, select a different sound. This decision can be changed in System Settings. - Math alarms Notifications for alarms App Updates Notifications about new app versions - - Done In a few mins - - Light - Dark - Use system default - - light - dark - system - - - @string/pref_theme_system_value - Alarms - " In order for alarms to work, Math alarm needs the permission to set alarms. If this permission is not granted, the existing alarms will not work as well." - Grant - Not now diff --git a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt deleted file mode 100644 index 22f4708e..00000000 --- a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.timilehinaregbesola.mathalarm.fake - -import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigator - -/** - * Fake implementation of ScreenNavigator for testing. - */ -class ScreenNavigatorFake : ScreenNavigator { - var exactAlarmPermissionScreenOpened = false - private set - var appSettingsOpened = false - private set - - override fun openExactAlarmPermissionScreen() { - exactAlarmPermissionScreenOpened = true - } - - override fun openAppSettings() { - appSettingsOpened = true - } - - fun reset() { - exactAlarmPermissionScreenOpened = false - appSettingsOpened = false - } -} diff --git a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt index 1fbfad97..158463ed 100644 --- a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt +++ b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt @@ -3,13 +3,11 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission import android.os.Build import com.timilehinaregbesola.mathalarm.fake.AndroidVersionFake import com.timilehinaregbesola.mathalarm.fake.PermissionCheckerFake -import com.timilehinaregbesola.mathalarm.fake.ScreenNavigatorFake import org.junit.Assert import org.junit.Before import org.junit.Test class AlarmPermissionTest { - private val screenNavigator = ScreenNavigatorFake() private val permissionChecker = PermissionCheckerFake() private val androidVersion = AndroidVersionFake() @@ -17,8 +15,7 @@ class AlarmPermissionTest { @Before fun setup() { - alarmPermission = AlarmPermissionImpl(screenNavigator, permissionChecker, androidVersion) - screenNavigator.reset() + alarmPermission = AlarmPermissionImpl(permissionChecker, androidVersion) } @Test @@ -43,25 +40,11 @@ class AlarmPermissionTest { @Test fun `test if Android below S returns true`() { - androidVersion.version = Build.VERSION_CODES.M + androidVersion.version = Build.VERSION_CODES.O permissionChecker.canScheduleExactAlarms = false // Should be ignored val result = alarmPermission.hasExactAlarmPermission() Assert.assertTrue(result) } - - @Test - fun `test openExactAlarmPermissionScreen delegates to navigator`() { - alarmPermission.openExactAlarmPermissionScreen() - - Assert.assertTrue(screenNavigator.exactAlarmPermissionScreenOpened) - } - - @Test - fun `test openAppSettings delegates to navigator`() { - alarmPermission.openAppSettings() - - Assert.assertTrue(screenNavigator.appSettingsOpened) - } } diff --git a/shared/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.android.kt b/shared/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.android.kt index d6a64cda..d2fb3002 100644 --- a/shared/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.android.kt +++ b/shared/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.android.kt @@ -2,7 +2,6 @@ package com.timilehinaregbesola.mathalarm.platform import android.content.Context import android.media.RingtoneManager -import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import androidx.core.app.NotificationManagerCompat @@ -17,12 +16,7 @@ actual class PlatformVibrator actual constructor() { actual fun startWaveform(pattern: LongArray, repeat: Int) { if (vibrator == null) return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createWaveform(pattern, repeat)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(pattern, repeat) - } + vibrator.vibrate(VibrationEffect.createWaveform(pattern, repeat)) } actual fun cancel() { diff --git a/shared/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt b/shared/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt index 1c19ec73..2234c078 100644 --- a/shared/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt +++ b/shared/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt @@ -1,7 +1,7 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission /** - * Platform-abstracted interface for checking and managing alarm permissions. + * Platform-abstracted interface for checking alarm permissions. */ @OptIn(kotlin.experimental.ExperimentalObjCRefinement::class) @kotlin.native.HiddenFromObjC @@ -12,16 +12,4 @@ interface AlarmPermission { * @return `true` if the permission is granted, `false` otherwise */ fun hasExactAlarmPermission(): Boolean - - /** - * Opens the system settings screen for exact alarm permission. - * On Android S+, this opens the SCHEDULE_EXACT_ALARM permission screen. - */ - fun openExactAlarmPermissionScreen() - - /** - * Opens the app settings screen. - * Useful for directing users to manually enable permissions. - */ - fun openAppSettings() } diff --git a/shared/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt b/shared/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt index 6dc20553..ec7e1382 100644 --- a/shared/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt +++ b/shared/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt @@ -7,27 +7,9 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissio class AlarmPermissionFake( private var hasPermission: Boolean = true ) : AlarmPermission { - var exactAlarmPermissionScreenOpened = false - private set - var appSettingsOpened = false - private set - override fun hasExactAlarmPermission(): Boolean = hasPermission - override fun openExactAlarmPermissionScreen() { - exactAlarmPermissionScreenOpened = true - } - - override fun openAppSettings() { - appSettingsOpened = true - } - fun setPermission(granted: Boolean) { hasPermission = granted } - - fun reset() { - exactAlarmPermissionScreenOpened = false - appSettingsOpened = false - } } diff --git a/shared/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt b/shared/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt index e4f75984..11965b7b 100644 --- a/shared/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt +++ b/shared/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt @@ -1,9 +1,6 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission import com.timilehinaregbesola.mathalarm.alarm.AlarmSchedulerBridge -import platform.Foundation.NSURL -import platform.UIKit.UIApplication -import platform.UIKit.UIApplicationOpenSettingsURLString /** AlarmKit authorization is required for the iOS/iPadOS 26+ release. */ @OptIn(kotlin.experimental.ExperimentalObjCRefinement::class) @@ -11,21 +8,4 @@ import platform.UIKit.UIApplicationOpenSettingsURLString class AlarmPermissionImpl : AlarmPermission { override fun hasExactAlarmPermission(): Boolean = AlarmSchedulerBridge.authorizationStatus() == "authorized" - - /** - * A denied AlarmKit permission can be changed in the app's Settings page. - */ - override fun openExactAlarmPermissionScreen() { - openAppSettings() - } - - /** - * Opens the iOS Settings app to this app's settings page. - */ - override fun openAppSettings() { - val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString) - if (url != null) { - UIApplication.sharedApplication.openURL(url) - } - } } From 9f9a92bb933ca17e06677d64c295f27a17c2679f Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:23:15 +0000 Subject: [PATCH 2/8] Reproduce malformed update messages and unreachable ringtone permissions --- .../mathalarm/RingtonePermissionDeviceTest.kt | 69 ++++++++ .../platform/RingtonePermissionTest.kt | 152 ++++++++++++++++++ .../utils/FirebaseMessagingServiceTest.kt | 101 ++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt create mode 100644 androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt create mode 100644 androidApp/src/test/java/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingServiceTest.kt diff --git a/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt new file mode 100644 index 00000000..75bb048c --- /dev/null +++ b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt @@ -0,0 +1,69 @@ +package com.timilehinaregbesola.mathalarm + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.SystemClock +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import com.timilehinaregbesola.mathalarm.platform.checkRingtonePermissions +import com.timilehinaregbesola.mathalarm.presentation.MainActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +@OptIn( + androidx.compose.foundation.ExperimentalFoundationApi::class, + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.ui.ExperimentalComposeUiApi::class, + kotlinx.coroutines.InternalCoroutinesApi::class, + androidx.compose.animation.ExperimentalAnimationApi::class, +) +class RingtonePermissionDeviceTest { + @Test + fun inaccessibleToneCanRequestAndReceiveStoragePermission() { + assumeTrue(Build.VERSION.SDK_INT in 26..32) + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + assertEquals(PackageManager.PERMISSION_DENIED, + context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + checkRingtonePermissions( + listOf("content://com.mathalarm.missing/audio/404"), + "Ringtone access check", + ) { "Cannot open $it" } + } + onView(withText("Ringtone access check")).check(matches(isDisplayed())) + onView(withText(android.R.string.ok)).perform(click()) + + val automation = instrumentation.uiAutomation + val deadline = SystemClock.uptimeMillis() + 10_000 + var allow: AccessibilityNodeInfo? = null + while (allow == null && SystemClock.uptimeMillis() < deadline) { + allow = automation.rootInActiveWindow?.findAccessibilityNodeInfosByViewId( + "com.android.permissioncontroller:id/permission_allow_button" + )?.firstOrNull() + if (allow == null) SystemClock.sleep(100) + } + assertNotNull("Android must display the storage permission prompt", allow) + assertTrue(allow!!.performAction(AccessibilityNodeInfo.ACTION_CLICK)) + + while (context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != + PackageManager.PERMISSION_GRANTED && SystemClock.uptimeMillis() < deadline) { + SystemClock.sleep(100) + } + assertEquals(PackageManager.PERMISSION_GRANTED, + context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) + } + } +} diff --git a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt new file mode 100644 index 00000000..2b80e626 --- /dev/null +++ b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt @@ -0,0 +1,152 @@ +package com.timilehinaregbesola.mathalarm.platform + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.app.Application +import android.content.Context +import android.media.Ringtone +import android.media.RingtoneManager +import android.net.Uri +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowAlertDialog +import org.robolectric.shadows.ShadowMediaPlayer +import org.robolectric.shadows.util.DataSource + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30], application = Application::class, shadows = [UnavailableRingtoneTitle::class]) +class RingtonePermissionTest { + private lateinit var activity: Activity + private val players = mutableListOf() + private val tone = "content://media/external/audio/media/404" + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.READ_EXTERNAL_STORAGE) + ShadowMediaPlayer.setCreateListener { _, player -> players += player } + } + + @After + fun tearDown() { + activity.finish() + stopKoin() + } + + @Test + fun `inaccessible tone prompts from application context wiring and requests permission on confirmation`() { + configureContext(RuntimeEnvironment.getApplication()) + rejectTone() + + checkTone() + + val dialog = ShadowAlertDialog.getLatestAlertDialog() + assertNotNull("The live activity must receive the permission dialog", dialog) + assertTrue(shadowOf(dialog).message.toString().contains(tone)) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + assertArrayEquals(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + shadowOf(activity).lastRequestedPermission.requestedPermissions) + assertEquals(3, shadowOf(activity).lastRequestedPermission.requestCode) + } + + @Test + fun `readable tone releases probe without prompting`() { + configureContext(activity) + ShadowMediaPlayer.addMediaInfo(DataSource.toDataSource(activity, Uri.parse(tone)), ShadowMediaPlayer.MediaInfo()) + + checkTone() + + assertEquals(1, players.size) + assertEquals(ShadowMediaPlayer.State.END, players.single().state) + assertNull(ShadowAlertDialog.getLatestAlertDialog()) + } + + @Test + fun `failed probe is released and title failure retains tone in dialog`() { + configureContext(activity) + rejectTone() + + checkTone() + + assertEquals(1, players.size) + assertEquals(ShadowMediaPlayer.State.END, players.single().state) + assertTrue(shadowOf(ShadowAlertDialog.getLatestAlertDialog()).message.toString().contains(tone)) + } + + @Test + fun `permission already granted does not probe or prompt`() { + configureContext(activity) + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.READ_EXTERNAL_STORAGE) + + checkTone() + + assertTrue(players.isEmpty()) + assertNull(ShadowAlertDialog.getLatestAlertDialog()) + } + + @Test + @Config(sdk = [35]) + fun `Android 13 and newer do not prompt for obsolete storage permission`() { + configureContext(activity) + rejectTone() + + checkTone() + + assertTrue(players.isEmpty()) + assertNull(ShadowAlertDialog.getLatestAlertDialog()) + assertNull(shadowOf(activity).lastRequestedPermission) + } + + @Test + fun `cancel leaves permission unrequested`() { + configureContext(activity) + rejectTone() + checkTone() + + val dialog = ShadowAlertDialog.getLatestAlertDialog() + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).performClick() + + assertFalse(dialog.isShowing) + assertNull(shadowOf(activity).lastRequestedPermission) + } + + private fun checkTone() { + checkRingtonePermissions(listOf(tone), "Ringtone") { "Cannot play $it" } + } + + private fun configureContext(context: Context) { + startKoin { modules(module { single { context } }) } + } + + private fun rejectTone() { + ShadowMediaPlayer.addException(DataSource.toDataSource(activity, Uri.parse(tone)), SecurityException("Denied")) + } +} + +@Implements(RingtoneManager::class) +class UnavailableRingtoneTitle { + companion object { + @JvmStatic + @Implementation + fun getRingtone(context: Context, uri: Uri): Ringtone? = throw SecurityException("Title inaccessible") + } +} diff --git a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingServiceTest.kt b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingServiceTest.kt new file mode 100644 index 00000000..e0dc42da --- /dev/null +++ b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingServiceTest.kt @@ -0,0 +1,101 @@ +package com.timilehinaregbesola.mathalarm.utils + +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import com.google.firebase.messaging.RemoteMessage +import com.timilehinaregbesola.mathalarm.BuildConfig +import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotificationChannel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30], application = Application::class) +class FirebaseMessagingServiceTest { + private lateinit var service: FirebaseMessagingService + private lateinit var notifications: NotificationManager + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + startKoin { modules(module { single { MathAlarmNotificationChannel(context) } }) } + service = Robolectric.buildService(FirebaseMessagingService::class.java).create().get() + notifications = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + @After + fun tearDown() { + service.onDestroy() + stopKoin() + } + + @Test + fun `missing update flag is ignored`() = assertIgnored(mapOf("title" to "Hello")) + + @Test + fun `missing version is ignored`() = assertIgnored(updatePayload() - "versionCode") + + @Test + fun `invalid update flag is ignored`() = assertIgnored(updatePayload() + ("updateApp" to "yes")) + + @Test + fun `invalid version is ignored`() = assertIgnored(updatePayload() + ("versionCode" to "latest")) + + @Test + fun `overflow version is ignored`() = assertIgnored(updatePayload() + ("versionCode" to "999999999999999999")) + + @Test + fun `irrelevant and incomplete messages are ignored`() { + assertIgnored(emptyMap()) + assertIgnored(updatePayload() + ("updateApp" to "0")) + assertIgnored(updatePayload() - "title") + assertIgnored(updatePayload() - "message") + } + + @Test + fun `current and older versions are ignored`() { + assertIgnored(updatePayload() + ("versionCode" to BuildConfig.VERSION_CODE.toString())) + assertIgnored(updatePayload() + ("versionCode" to (BuildConfig.VERSION_CODE - 1).toString())) + } + + @Test + fun `newer version posts update notification with title body and store action`() { + receive(updatePayload()) + + val notification = shadowOf(notifications).allNotifications.single() + assertEquals("Update available", notification.extras.getString(Notification.EXTRA_TITLE)) + assertEquals("New version", notification.extras.getString(Notification.EXTRA_TEXT)) + assertEquals(MathAlarmNotificationChannel.UPDATE_CHANNEL_ID, notification.channelId) + assertEquals("https://play.google.com/store/apps/details?id=${service.packageName}", + shadowOf(notification.actions.single().actionIntent).savedIntent.dataString) + } + + private fun assertIgnored(payload: Map) { + receive(payload) + assertTrue(shadowOf(notifications).allNotifications.isEmpty()) + } + + private fun receive(payload: Map) { + service.onMessageReceived(RemoteMessage.Builder("test").setData(payload).build()) + } + + private fun updatePayload() = mapOf( + "updateApp" to "1", + "versionCode" to (BuildConfig.VERSION_CODE + 1).toString(), + "title" to "Update available", + "message" to "New version", + ) +} From bb682f0ac55ea59d9a7263473e2fffbbe998040c Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:25:26 +0000 Subject: [PATCH 3/8] Ignore malformed or irrelevant app update messages --- .../utils/FirebaseMessagingService.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingService.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingService.kt index 2cc41948..35644821 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingService.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/utils/FirebaseMessagingService.kt @@ -27,19 +27,13 @@ import androidx.core.net.toUri class FirebaseMessagingService : FirebaseMessagingService() { val channel: MathAlarmNotificationChannel by inject() override fun onMessageReceived(remoteMessage: RemoteMessage) { - if (remoteMessage.data.isNotEmpty()) { - val updateApp = remoteMessage.data["updateApp"]!!.toInt() - val versionCode = remoteMessage.data["versionCode"]!!.toInt() - val title = remoteMessage.data["title"] - val message = remoteMessage.data["message"] - if (updateApp == 1) { - if (BuildConfig.VERSION_CODE < versionCode) { - if (title != null && message != null) { - sendUpdateApp(title, message) - } - } - } - } + val data = remoteMessage.data + if (data["updateApp"]?.toIntOrNull() != 1) return + val versionCode = data["versionCode"]?.toIntOrNull() ?: return + if (versionCode <= BuildConfig.VERSION_CODE) return + val title = data["title"] ?: return + val message = data["message"] ?: return + sendUpdateApp(title, message) } @SuppressLint("UnspecifiedImmutableFlag", "MutableImplicitPendingIntent") From 86a1e481e974a1040b970c4827aee9dd4a9b40fe Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:28:35 +0000 Subject: [PATCH 4/8] Use live activity for ringtone permission and release temporary players --- .../mathalarm/RingtonePermissionDeviceTest.kt | 2 + .../mathalarm/platform/PlatformApis.kt | 49 +++++++------- .../components/AlarmBottomSheet.kt | 4 +- .../platform/RingtonePermissionTest.kt | 66 ++++++++++++++++++- 4 files changed, 95 insertions(+), 26 deletions(-) diff --git a/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt index 75bb048c..175e4f13 100644 --- a/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt +++ b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt @@ -37,8 +37,10 @@ class RingtonePermissionDeviceTest { context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) ActivityScenario.launch(MainActivity::class.java).use { scenario -> + instrumentation.waitForIdleSync() scenario.onActivity { activity -> checkRingtonePermissions( + activity, listOf("content://com.mathalarm.missing/audio/404"), "Ringtone access check", ) { "Cannot open $it" } diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt index 5b4bd2b2..a224af62 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/platform/PlatformApis.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.media.MediaPlayer import android.media.RingtoneManager -import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.LocalActivity @@ -50,7 +49,12 @@ fun interface RingtonePickerLauncher { fun getRingtoneTitle(alarmTone: String): String { val context: Context = getKoinContext() return try { - RingtoneManager.getRingtone(context, alarmTone.toUri()).getTitle(context) + val ringtone = RingtoneManager.getRingtone(context, alarmTone.toUri()) + try { + ringtone?.getTitle(context).orEmpty() + } finally { + ringtone?.stop() + } } catch (_: Exception) { "" } @@ -163,36 +167,35 @@ fun rememberNotificationPermissionHandler(onResult: (Boolean) -> Unit): () -> Un } fun checkRingtonePermissions( + activity: Activity, tones: List, unplayableDialogTitle: String, unplayableDialogMessage: (String) -> String ) { - val context: Context = getKoinContext() - val activity = (context as? Activity) ?: return - - if (Build.VERSION.SDK_INT >= 23 && + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 && activity.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ) { val unplayable = tones - .filter { alarmtone -> - runCatching { - val player = MediaPlayer() - player.setDataSource(activity, alarmtone.toUri()) - player.apply { - setOnErrorListener { mp, _, _ -> - Logger.e("Error occurred while playing audio.") - mp.stop() - mp.release() - true - } - } - }.isFailure + .filter { tone -> + val player = MediaPlayer() + try { + player.setDataSource(activity, tone.toUri()) + false + } catch (_: Exception) { + true + } finally { + player.release() + } } - .mapNotNull { tone -> RingtoneManager.getRingtone(activity, Uri.parse(tone)) } - .map { ringtone -> + .map { tone -> runCatching { - ringtone.getTitle(activity) ?: "null" - }.getOrDefault("null") + val ringtone = RingtoneManager.getRingtone(activity, tone.toUri()) + try { + ringtone?.getTitle(activity) + } finally { + ringtone?.stop() + } + }.getOrNull()?.takeIf { it.isNotBlank() } ?: tone } if (unplayable.isNotEmpty()) { diff --git a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt index 5f7db2f4..901eae8b 100644 --- a/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt +++ b/androidApp/src/main/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt @@ -3,6 +3,7 @@ package com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components import com.timilehinaregbesola.mathalarm.utils.resolve import com.timilehinaregbesola.mathalarm.presentation.alarmlist.components.AlarmPermissionDialog +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.SpaceBetween import androidx.compose.foundation.layout.BoxWithConstraints @@ -152,9 +153,10 @@ fun AlarmBottomSheet( val alertTitle = strings.alert val storagePermissionTextFn = strings.permissionsExternalStorageText + val activity = LocalActivity.current val pickToneLauncher = rememberRingtonePickerLauncher { selectedTone -> selectedTone?.let { tone -> - checkRingtonePermissions(listOf(tone), alertTitle, storagePermissionTextFn) + activity?.let { checkRingtonePermissions(it, listOf(tone), alertTitle, storagePermissionTextFn) } viewModel.onEvent(OnToneChange(tone)) } } diff --git a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt index 2b80e626..9b23af39 100644 --- a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt +++ b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/RingtonePermissionTest.kt @@ -8,6 +8,10 @@ import android.content.Context import android.media.Ringtone import android.media.RingtoneManager import android.net.Uri +import android.os.Looper +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -41,6 +45,7 @@ class RingtonePermissionTest { @Before fun setUp() { + UnavailableRingtoneTitle.ringtone = null activity = Robolectric.buildActivity(Activity::class.java).setup().get() shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.READ_EXTERNAL_STORAGE) ShadowMediaPlayer.setCreateListener { _, player -> players += player } @@ -63,6 +68,7 @@ class RingtonePermissionTest { assertNotNull("The live activity must receive the permission dialog", dialog) assertTrue(shadowOf(dialog).message.toString().contains(tone)) dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + shadowOf(Looper.getMainLooper()).idle() assertArrayEquals(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), shadowOf(activity).lastRequestedPermission.requestedPermissions) assertEquals(3, shadowOf(activity).lastRequestedPermission.requestCode) @@ -124,13 +130,66 @@ class RingtonePermissionTest { val dialog = ShadowAlertDialog.getLatestAlertDialog() dialog.getButton(AlertDialog.BUTTON_NEGATIVE).performClick() + shadowOf(Looper.getMainLooper()).idle() assertFalse(dialog.isShowing) assertNull(shadowOf(activity).lastRequestedPermission) } + @Test + fun `unplayable tone title lookup releases its ringtone`() { + configureContext(activity) + rejectTone() + val ringtone = mockk(relaxed = true) + every { ringtone.getTitle(activity) } returns "Selected tone" + UnavailableRingtoneTitle.ringtone = ringtone + + checkTone() + + assertTrue(shadowOf(ShadowAlertDialog.getLatestAlertDialog()).message.toString().contains("Selected tone")) + verify(exactly = 1) { ringtone.stop() } + } + + @Test + fun `failed title lookup releases its ringtone and retains URI`() { + configureContext(activity) + rejectTone() + val ringtone = mockk(relaxed = true) + every { ringtone.getTitle(activity) } throws SecurityException("Denied") + UnavailableRingtoneTitle.ringtone = ringtone + + checkTone() + + assertTrue(shadowOf(ShadowAlertDialog.getLatestAlertDialog()).message.toString().contains(tone)) + verify(exactly = 1) { ringtone.stop() } + } + + @Test + fun `display title lookup releases its ringtone`() { + configureContext(activity) + val ringtone = mockk(relaxed = true) + every { ringtone.getTitle(activity) } returns "Selected tone" + UnavailableRingtoneTitle.ringtone = ringtone + + assertEquals("Selected tone", getRingtoneTitle(tone)) + + verify(exactly = 1) { ringtone.stop() } + } + + @Test + fun `failed display title lookup releases its ringtone`() { + configureContext(activity) + val ringtone = mockk(relaxed = true) + every { ringtone.getTitle(activity) } throws SecurityException("Denied") + UnavailableRingtoneTitle.ringtone = ringtone + + assertEquals("", getRingtoneTitle(tone)) + + verify(exactly = 1) { ringtone.stop() } + } + private fun checkTone() { - checkRingtonePermissions(listOf(tone), "Ringtone") { "Cannot play $it" } + checkRingtonePermissions(activity, listOf(tone), "Ringtone") { "Cannot play $it" } } private fun configureContext(context: Context) { @@ -145,8 +204,11 @@ class RingtonePermissionTest { @Implements(RingtoneManager::class) class UnavailableRingtoneTitle { companion object { + var ringtone: Ringtone? = null + @JvmStatic @Implementation - fun getRingtone(context: Context, uri: Uri): Ringtone? = throw SecurityException("Title inaccessible") + fun getRingtone(context: Context, uri: Uri): Ringtone? = + ringtone ?: throw SecurityException("Title inaccessible") } } From f57ea864431ee1cfd5586dbf91064c8db982fa77 Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:40:01 +0000 Subject: [PATCH 5/8] Make ringtone permission verification repeatable on a dedicated emulator --- .../mathalarm/RingtonePermissionDeviceTest.kt | 5 ++- .../verify_android_ringtone_permissions.sh | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 scripts/verify_android_ringtone_permissions.sh diff --git a/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt index 175e4f13..e35f34cb 100644 --- a/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt +++ b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/RingtonePermissionDeviceTest.kt @@ -9,6 +9,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry @@ -45,8 +46,8 @@ class RingtonePermissionDeviceTest { "Ringtone access check", ) { "Cannot open $it" } } - onView(withText("Ringtone access check")).check(matches(isDisplayed())) - onView(withText(android.R.string.ok)).perform(click()) + onView(withText("Ringtone access check")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click()) val automation = instrumentation.uiAutomation val deadline = SystemClock.uptimeMillis() + 10_000 diff --git a/scripts/verify_android_ringtone_permissions.sh b/scripts/verify_android_ringtone_permissions.sh new file mode 100644 index 00000000..b9514409 --- /dev/null +++ b/scripts/verify_android_ringtone_permissions.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail +serial="${1:?Usage: verify_android_ringtone_permissions.sh emulator-SERIAL (dedicated API26..32 emulator with app and test APKs installed)}" +case "$serial" in + emulator-*) ;; + *) echo 'Pass an explicit dedicated emulator serial.' >&2; exit 2 ;; +esac +out="${ANDROID_UI_OUTPUT:-build/alarm-device-results/ringtone-permissions}" +package='com.timilehinaregbesola.mathalarm.debug' +permission='android.permission.READ_EXTERNAL_STORAGE' +sdk="$(adb -s "$serial" shell getprop ro.build.version.sdk | tr -d '\r')" +if (( sdk < 26 || sdk > 32 )); then + echo "Storage permission verification requires API26..32, found API$sdk." >&2 + exit 2 +fi +adb -s "$serial" shell pm path "$package" | grep -q '^package:' +adb -s "$serial" shell pm path "$package.test" | grep -q '^package:' +mkdir -p "$out" +reset_permission() { + adb -s "$serial" shell am force-stop "$package" + adb -s "$serial" shell pm revoke "$package" "$permission" +} +cleanup() { + result=$? + trap - EXIT + if ! reset_permission; then + echo 'Could not restore the denied storage permission fixture.' >&2 + result=1 + fi + exit "$result" +} +# Revoking this permission kills the target process, so cleanup must outlive instrumentation. +trap cleanup EXIT +reset_permission +adb -s "$serial" shell input keyevent KEYCODE_WAKEUP +adb -s "$serial" shell wm dismiss-keyguard +adb -s "$serial" shell am instrument -w -r \ + -e class com.timilehinaregbesola.mathalarm.RingtonePermissionDeviceTest \ + "$package.test/androidx.test.runner.AndroidJUnitRunner" > "$out/result.txt" +cat "$out/result.txt" +grep -q '^OK (1 test)' "$out/result.txt" +! grep -Eq 'FAILURES|INSTRUMENTATION_FAILED|INSTRUMENTATION_STATUS_CODE: -[1234]' "$out/result.txt" From c2906869cd0e8ee849e2267e02619ba5ba9e6f11 Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:40:01 +0000 Subject: [PATCH 6/8] Verify saved theme during real Android application startup --- .../mathalarm/SavedThemeStartupTest.kt | 17 ++++ scripts/verify_android_saved_theme.py | 81 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/SavedThemeStartupTest.kt create mode 100644 scripts/verify_android_saved_theme.py diff --git a/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/SavedThemeStartupTest.kt b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/SavedThemeStartupTest.kt new file mode 100644 index 00000000..32637784 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/com/timilehinaregbesola/mathalarm/SavedThemeStartupTest.kt @@ -0,0 +1,17 @@ +package com.timilehinaregbesola.mathalarm + +import androidx.appcompat.app.AppCompatDelegate +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Test + +class SavedThemeStartupTest { + @Test + fun savedThemeIsAppliedWhenTheApplicationStarts() { + val expected = InstrumentationRegistry.getArguments().getString("expectedNightMode") + assumeTrue("Run scripts/verify_android_saved_theme.py to seed the startup preference", expected != null) + + assertEquals(expected!!.toInt(), AppCompatDelegate.getDefaultNightMode()) + } +} diff --git a/scripts/verify_android_saved_theme.py b/scripts/verify_android_saved_theme.py new file mode 100644 index 00000000..f92e6abf --- /dev/null +++ b/scripts/verify_android_saved_theme.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Verify saved theme startup on an explicitly selected dedicated emulator.""" +import argparse +from pathlib import Path +import re +import subprocess +import xml.etree.ElementTree as ET + +PACKAGE = "com.timilehinaregbesola.mathalarm.debug" +PREFS = f"shared_prefs/{PACKAGE}_preferences.xml" +KEY = "mathalarm_theme_option" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("serial", help="Dedicated emulator serial with app and test APKs installed") + parser.add_argument("--output", type=Path, default=Path("build/alarm-device-results/saved-theme")) + args = parser.parse_args() + if not args.serial.startswith("emulator-"): + parser.error("Pass an explicit dedicated emulator serial") + + def adb(*command, data=None, check=True): + return subprocess.run(["adb", "-s", args.serial, *command], input=data, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check) + + def read_pref(path): + exists = adb("shell", "run-as", PACKAGE, "test", "-f", path, check=False) + if exists.returncode: + return None + return adb("exec-out", "run-as", PACKAGE, "cat", path).stdout + + def write_pref(path, data): + if data is None: + adb("shell", "run-as", PACKAGE, "rm", "-f", path) + else: + adb("shell", "run-as", PACKAGE, "mkdir", "-p", "shared_prefs") + adb("shell", f"run-as {PACKAGE} sh -c 'cat > {path}'", data=data) + + for package in (PACKAGE, f"{PACKAGE}.test"): + if not adb("shell", "pm", "path", package).stdout.startswith(b"package:"): + raise RuntimeError(f"Install {package} before running this check") + args.output.mkdir(parents=True, exist_ok=True) + adb("shell", "am", "force-stop", PACKAGE) + backups = {path: read_pref(path) for path in (PREFS, f"{PREFS}.bak")} + original = backups[f"{PREFS}.bak"] or backups[PREFS] or b"" + try: + for label, stored, expected in ( + ("light", 0, 1), ("dark", 1, 2), ("system", 2, -1), + ("invalid", 98765, -1), ("missing", None, -1), + ): + prefs = ET.fromstring(original) + for item in list(prefs): + if item.get("name") == KEY: + prefs.remove(item) + if stored is not None: + ET.SubElement(prefs, "int", name=KEY, value=str(stored)) + adb("shell", "am", "force-stop", PACKAGE) + write_pref(f"{PREFS}.bak", None) + write_pref(PREFS, ET.tostring(prefs, encoding="utf-8", xml_declaration=True)) + result = adb("shell", "am", "instrument", "-w", "-r", "-e", "class", + "com.timilehinaregbesola.mathalarm.SavedThemeStartupTest", + "-e", "expectedNightMode", str(expected), + f"{PACKAGE}.test/androidx.test.runner.AndroidJUnitRunner") + output = result.stdout.decode() + (args.output / f"{label}.txt").write_text(output) + if not re.search(r"^OK \(1 test\)", output, re.MULTILINE) or re.search( + r"FAILURES|INSTRUMENTATION_FAILED|INSTRUMENTATION_STATUS_CODE: -[1234]", output + ): + raise AssertionError(f"{label} startup failed; see {args.output / (label + '.txt')}") + print(f"{label}: saved={stored}, startup night mode={expected}, passed", flush=True) + finally: + adb("shell", "am", "force-stop", PACKAGE) + for path, data in backups.items(): + write_pref(path, data) + if read_pref(path) != data: + raise AssertionError(f"Failed to restore {path}") + print("Original preference files restored", flush=True) + + +if __name__ == "__main__": + main() From 73a56bc6bc53008f42a7fd5edd4301cdb941076f Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:42:47 +0000 Subject: [PATCH 7/8] Verify settings and preview vibration at Android API 26 --- .../platform/MinimumAndroidPlatformTest.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/MinimumAndroidPlatformTest.kt diff --git a/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/MinimumAndroidPlatformTest.kt b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/MinimumAndroidPlatformTest.kt new file mode 100644 index 00000000..e9211c79 --- /dev/null +++ b/androidApp/src/test/java/com/timilehinaregbesola/mathalarm/platform/MinimumAndroidPlatformTest.kt @@ -0,0 +1,65 @@ +package com.timilehinaregbesola.mathalarm.platform + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Vibrator +import android.provider.Settings +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26], application = Application::class) +class MinimumAndroidPlatformTest { + private lateinit var context: Application + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + startKoin { modules(module { single { context } }) } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `notification settings opens this app's settings from application context`() { + openNotificationSettings() + + val intent = shadowOf(context).nextStartedActivity + assertEquals(Settings.ACTION_APP_NOTIFICATION_SETTINGS, intent.action) + assertEquals(context.packageName, intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)) + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun `preview vibration retains waveform repeat and cancellation on minimum supported Android`() { + val vibrator = shadowOf(context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator) + val preview = PlatformVibrator() + val pattern = longArrayOf(0, 100, 200, 100) + + preview.startWaveform(pattern, 1) + + assertTrue(vibrator.isVibrating) + assertArrayEquals(pattern, vibrator.pattern) + assertEquals(1, vibrator.repeat) + + preview.cancel() + + assertTrue(vibrator.isCancelled) + } +} From b37f2da5ce9dd748ed0e6fdd6f2368ae01b1fdd7 Mon Sep 17 00:00:00 2001 From: t-regbs Date: Fri, 6 Nov 2026 20:46:47 +0000 Subject: [PATCH 8/8] fix(testing): preserve executable permission runner mode --- scripts/verify_android_ringtone_permissions.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/verify_android_ringtone_permissions.sh diff --git a/scripts/verify_android_ringtone_permissions.sh b/scripts/verify_android_ringtone_permissions.sh old mode 100644 new mode 100755