Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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.RootMatchers.isDialog
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 ->
instrumentation.waitForIdleSync()
scenario.onActivity { activity ->
checkRingtonePermissions(
activity,
listOf("content://com.mathalarm.missing/audio/404"),
"Ringtone access check",
) { "Cannot open $it" }
}
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
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))
}
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
9 changes: 0 additions & 9 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,6 @@
android:name=".notification.AlarmService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- <receiver android:name="com.timilehinaregbesola.mathalarm.RebootReceiver"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.BOOT_COMPLETED" />-->
<!-- <action android:name="android.intent.action.QUICKBOOT_POWERON"/>-->
<!-- <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- </intent-filter>-->
<!-- </receiver>-->
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,12 +116,10 @@ val androidModule = module {

// Permission abstractions
single<AndroidVersion> { AndroidVersionImpl() }
single<ScreenNavigator> { ScreenNavigatorImpl(androidContext()) }
single<PermissionChecker> { PermissionCheckerImpl(androidContext().getAlarmManager()) }

single<AlarmPermission> {
AlarmPermissionImpl(
screenNavigator = get(),
permissionChecker = get(),
androidVersion = get()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,12 +22,4 @@ class AlarmPermissionImpl(
true
}
}

override fun openExactAlarmPermissionScreen() {
screenNavigator.openExactAlarmPermissionScreen()
}

override fun openAppSettings() {
screenNavigator.openAppSettings()
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,26 +49,25 @@ 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) {
""
}
}

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() {
Expand Down Expand Up @@ -169,36 +167,35 @@ fun rememberNotificationPermissionHandler(onResult: (Boolean) -> Unit): () -> Un
}

fun checkRingtonePermissions(
activity: Activity,
tones: List<String>,
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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
Expand Down
Loading
Loading