Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions .github/workflows/android_build.yml

This file was deleted.

45 changes: 45 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Build & Test

on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]

jobs:
build:
name: Build & Test
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}

# Runs commonTest + androidHostTest for core module
- name: Run Core Module Tests
run: ./gradlew :core:testAndroidHostTest

# Runs commonTest + Android unit tests for app module
- name: Run App Module Tests
run: ./gradlew :app:testDebugUnitTest

- name: Build Debug APK
run: ./gradlew :app:assembleDebug

- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
retention-days: 7
6 changes: 6 additions & 0 deletions .idea/ChatHistory_schema_v4.xml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .idea/artifacts/core.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added .idea/firebender/chat_history.db
Binary file not shown.
Binary file added .idea/firebender/chat_history.db-shm
Binary file not shown.
Binary file added .idea/firebender/chat_history.db-wal
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
@file:OptIn(ExperimentalSwiftExportDsl::class)

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.swiftexport.ExperimentalSwiftExportDsl

plugins {
alias(libs.plugins.android.gradle)
Expand Down Expand Up @@ -174,6 +177,7 @@ android {

testOptions {
unitTests.isReturnDefaultValues = true
unitTests.isIncludeAndroidResources = true
}
}

Expand All @@ -182,6 +186,8 @@ dependencies {

testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
testImplementation(libs.mockk)
Expand Down
5 changes: 5 additions & 0 deletions app/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.RECEIVE_LAUNCH_BROADCASTS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
Expand Down Expand Up @@ -50,6 +51,10 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".notification.AlarmService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- <receiver android:name="com.timilehinaregbesola.mathalarm.RebootReceiver"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,18 @@ import android.content.Context
import android.content.Intent
import android.os.PowerManager
import co.touchlab.kermit.Logger
import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope
import com.timilehinaregbesola.mathalarm.framework.Usecases
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

/**
* [BroadcastReceiver] to be notified by the [android.app.AlarmManager].
*/
class AlarmReceiver : BroadcastReceiver(), KoinComponent {
val usecases: Usecases by inject()
private val usecases: Usecases by inject()
private val appScope: AppCoroutineScope by inject()

@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) {
Logger.d("onReceive() - intent ${intent.action}")

Expand All @@ -33,21 +31,27 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent {
)
wakelock.acquire(3000)
}
GlobalScope.launch {

appScope.launch {
handleIntent(intent)
}
}

private suspend fun handleIntent(intent: Intent?): Unit? {
return when (intent?.action) {
private suspend fun handleIntent(intent: Intent?) {
when (intent?.action) {
ALARM_ACTION -> getAlarmId(intent)?.let { usecases.showAlarm(it) }
COMPLETE_ACTION -> getAlarmId(intent)?.let { usecases.completeAlarm(it) }
SNOOZE_ACTION -> getAlarmId(intent)?.let { usecases.snoozeAlarm(it) }
DISMISS_ACTION -> {
// User swiped away the notification - immediately re-show it
Logger.d("Notification dismissed by user, re-showing alarm")
getAlarmId(intent)?.let { usecases.showAlarm(it) }
}
Intent.ACTION_BOOT_COMPLETED,
"android.intent.action.QUICKBOOT_POWERON",
"android.intent.action.MY_PACKAGE_REPLACED",
AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
Logger.d("Reboot Reboot!!")
Logger.d("Rescheduling alarms after system event")
usecases.rescheduleFutureAlarms()
}
else -> {
Expand All @@ -68,5 +72,7 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent {
const val COMPLETE_ACTION = "com.timilehinaregbesola.mathalarm.SET_COMPLETE"

const val SNOOZE_ACTION = "com.timilehinaregbesola.mathalarm.SNOOZE"

const val DISMISS_ACTION = "com.timilehinaregbesola.mathalarm.NOTIFICATION_DISMISSED"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissio
import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissionImpl
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 All @@ -28,6 +32,7 @@ import com.timilehinaregbesola.mathalarm.interactors.PlayerWrapper
import com.timilehinaregbesola.mathalarm.notification.AlarmNotificationScheduler
import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotification
import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotificationChannel
import com.timilehinaregbesola.mathalarm.notification.PendingIntentIdGenerator
import com.timilehinaregbesola.mathalarm.utils.getAlarmManager
import kotlinx.coroutines.InternalCoroutinesApi
import org.koin.android.ext.koin.androidApplication
Expand Down Expand Up @@ -59,6 +64,10 @@ val androidModule = module {

single { get<AlarmDatabase>().alarmDatabaseDao }

single { PendingIntentIdGenerator() }

single { AlarmNotificationScheduler(androidContext(), getWith("AlarmNotificationScheduler"), get()) }

// Android Alarm Interactor
single<AlarmInteractor> { AlarmInteractorImpl(get(), getWith("AlarmInteractorImpl")) }

Expand All @@ -75,8 +84,6 @@ val androidModule = module {

single { MathAlarmNotificationChannel(androidContext()) }

single { AlarmNotificationScheduler(androidContext(), getWith("AlarmNotificationScheduler")) }

@OptIn(
ExperimentalAnimationApi::class,
InternalCoroutinesApi::class,
Expand All @@ -86,19 +93,25 @@ val androidModule = module {
)
single<NotificationInteractor> {
NotificationInteractorImpl(
get(),
androidContext(),
getWith("NotificationInteractorImpl")
)
}

// Android Audio Player
single<AudioPlayer> { PlayerWrapper(androidContext(), getWith("PlayerWrapper")) }

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

single<AlarmPermission> {
AlarmPermissionImpl(androidContext().getAlarmManager(), get())
AlarmPermissionImpl(
screenNavigator = get(),
permissionChecker = get(),
androidVersion = get()
)
}

// Platform Logger - Android-specific with Crashlytics integration
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission

import android.annotation.SuppressLint
import android.app.AlarmManager
import android.os.Build

/**
* Android implementation of [AlarmPermission] using abstracted dependencies.
*/
class AlarmPermissionImpl(
private val alarmManager: AlarmManager?,
private val screenNavigator: ScreenNavigator,
private val permissionChecker: PermissionChecker,
private val androidVersion: AndroidVersion
) : AlarmPermission {

Expand All @@ -14,14 +16,19 @@ class AlarmPermissionImpl(
*
* @return `true` if the permission is granted, `false` otherwise
*/
@SuppressLint("NewApi")
override fun hasExactAlarmPermission(): Boolean {
if (alarmManager == null) return false

return if (androidVersion.currentVersion >= Build.VERSION_CODES.S) {
alarmManager.canScheduleExactAlarms()
permissionChecker.canScheduleExactAlarms()
} else {
true
}
}

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

override fun openAppSettings() {
screenNavigator.openAppSettings()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission

/**
* Abstraction for checking system permissions.
* Extracted for better testability.
*/
interface PermissionChecker {
/**
* Checks if the app can schedule exact alarms.
* @return true if exact alarms can be scheduled
*/
fun canScheduleExactAlarms(): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission

import android.annotation.SuppressLint
import android.app.AlarmManager
import android.os.Build

/**
* Android implementation of [PermissionChecker].
*/
class PermissionCheckerImpl(
private val alarmManager: AlarmManager?
) : PermissionChecker {

@SuppressLint("NewApi")
override fun canScheduleExactAlarms(): Boolean {
if (alarmManager == null) return false

return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
alarmManager.canScheduleExactAlarms()
} else {
true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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)
}
}
Loading