From 04a55e647248fa8db657773277db4ec0f6cfdc91 Mon Sep 17 00:00:00 2001 From: Cavin Date: Wed, 4 Jun 2025 03:25:15 +0530 Subject: [PATCH 1/6] Refactor: Introduce TriggerX library for scheduling alarms and managing permissions This commit introduces the `triggerx` library, which provides functionalities for scheduling exact alarms and managing necessary permissions (Alarm, Overlay, Battery Optimization) in a Jetpack Compose-friendly way. Key changes: - **TriggerX Library Module (`triggerx`)**: - `TriggerXAlarmScheduler`: Manages scheduling and cancelling of exact alarms using `AlarmManager`. - `TriggerXAlarmReceiver`: `BroadcastReceiver` to handle alarm intents and launch `TriggerActivity`. - `TriggerActivity`: Simple activity displayed when an alarm triggers, showing "ALARM!" text. - `permission/TriggerXPermissionManager.kt`: - `AlarmPermissionManager`: Provides utility functions to check and request permissions for exact alarms, overlay, and battery optimization. - `PermissionType` enum: Defines the types of permissions managed. - `PermissionState`: A state holder class for Compose to manage the lifecycle and UI of permission requests. - `permission/TriggerXPermissionComposable.kt`: - `rememberAppPermissionState()`: Composable function to create and remember `PermissionState`. - `PermissionLifeCycleCheckEffect()`: Composable to handle permission checks on lifecycle events (e.g., `ON_RESUME`). - `ShowPopup()`: Composable to display a generic rationale/information dialog. - `AndroidManifest.xml`: Declares necessary permissions (`SCHEDULE_EXACT_ALARM`, `FOREGROUND_SERVICE`, `POST_NOTIFICATIONS`, `SYSTEM_ALERT_WINDOW`, `WAKE_LOCK`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`), `TriggerActivity`, and `TriggerXAlarmReceiver`. - `build.gradle.kts`: Configured with necessary dependencies including Hilt, Compose, and Kotlin serialization. - `.gitignore` and Proguard rules added. - **App Module (`app`)**: - `MainActivity`: Updated to use `HomeScreen` as its content. - `HomeScreen.kt`: New Composable screen demonstrating the usage of `rememberAppPermissionState` to request permissions and `TriggerXAlarmScheduler` (via `HomeViewModel`) to schedule an alarm. - `HomeViewModel.kt`: New ViewModel that uses `TriggerXAlarmScheduler` to schedule and cancel alarms. Injected using Hilt. - `TriggerXApplication.kt`: New `Application` class annotated with `@HiltAndroidApp` for Hilt integration. - `AndroidManifest.xml`: Updated to use `TriggerXApplication`. - `build.gradle.kts`: Added Hilt dependencies and `triggerx` module dependency. - **Project Level**: - `settings.gradle.kts`: Includes the new `triggerx` module and enables `TYPESAFE_PROJECT_ACCESSORS`. - `build.gradle.kts`: Added plugin aliases for `android.library`, `hilt`, and `ksp`. - `gradle/libs.versions.toml`: Updated Kotlin version and added versions/libraries for Hilt, KSP, and Kotlin Serialization. - IDE configuration files (`.idea/deploymentTargetSelector.xml`, `.idea/vcs.xml`, updated `.idea/gradle.xml`, `.idea/.name`) added/updated. This refactoring centralizes alarm and permission logic into a reusable library, making the main application cleaner and promoting better separation of concerns. --- .idea/.name | 2 +- .idea/deploymentTargetSelector.xml | 10 ++ .idea/gradle.xml | 1 + .idea/vcs.xml | 6 + app/build.gradle.kts | 23 ++- app/src/main/AndroidManifest.xml | 2 +- .../meticha/triggerxexample/MainActivity.kt | 33 +--- .../triggerxexample/TriggerXApplication.kt | 7 + .../triggerxexample/home/HomeScreen.kt | 50 ++++++ .../triggerxexample/home/HomeViewModel.kt | 32 ++++ build.gradle.kts | 3 + gradle/libs.versions.toml | 18 +- settings.gradle.kts | 5 +- triggerx/.gitignore | 1 + triggerx/build.gradle.kts | 63 +++++++ triggerx/consumer-rules.pro | 0 triggerx/proguard-rules.pro | 21 +++ .../triggerx/ExampleInstrumentedTest.kt | 24 +++ triggerx/src/main/AndroidManifest.xml | 36 ++++ .../com/meticha/triggerx/TriggerActivity.kt | 42 +++++ .../triggerx/TriggerXAlarmScheduler.kt | 83 +++++++++ .../TriggerXPermissionComposable.kt | 144 ++++++++++++++++ .../permission/TriggerXPermissionManager.kt | 157 ++++++++++++++++++ .../receivers/TriggerXAlarmReceiver.kt | 48 ++++++ .../com/meticha/triggerx/ExampleUnitTest.kt | 17 ++ 25 files changed, 792 insertions(+), 36 deletions(-) create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/vcs.xml create mode 100644 app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt create mode 100644 app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt create mode 100644 app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt create mode 100644 triggerx/.gitignore create mode 100644 triggerx/build.gradle.kts create mode 100644 triggerx/consumer-rules.pro create mode 100644 triggerx/proguard-rules.pro create mode 100644 triggerx/src/androidTest/java/com/meticha/triggerx/ExampleInstrumentedTest.kt create mode 100644 triggerx/src/main/AndroidManifest.xml create mode 100644 triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt create mode 100644 triggerx/src/test/java/com/meticha/triggerx/ExampleUnitTest.kt diff --git a/.idea/.name b/.idea/.name index 2cfa884..67f3ec4 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -TriggerX Example \ No newline at end of file +TriggerX-Example \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 97f0a8e..eb7d3de 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -10,6 +10,7 @@ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6799f4f..8d04c95 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) } android { @@ -40,20 +42,35 @@ android { } dependencies { - + // Core libraries implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) + + // Jetpack Compose implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + + // Hilt dependencies + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + implementation(projects.triggerx) + + // Unit testing testImplementation(libs.junit) + + // Android UI testing androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) + + // Debug-only tools debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47b35be..6c97255 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - diff --git a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt index 3a86c68..38c6e68 100644 --- a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt +++ b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt @@ -4,44 +4,21 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import com.meticha.triggerxexample.home.HomeScreen import com.meticha.triggerxexample.ui.theme.TriggerXExampleTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { TriggerXExampleTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + HomeScreen() } } } } -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - TriggerXExampleTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt b/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt new file mode 100644 index 0000000..e555ee4 --- /dev/null +++ b/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt @@ -0,0 +1,7 @@ +package com.meticha.triggerxexample + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TriggerXApplication: Application() \ No newline at end of file diff --git a/app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt b/app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt new file mode 100644 index 0000000..ec3d4cf --- /dev/null +++ b/app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt @@ -0,0 +1,50 @@ +package com.meticha.triggerxexample.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.meticha.triggerx.permission.rememberAppPermissionState + +@Composable +fun HomeScreen( + viewModel: HomeViewModel = hiltViewModel() +) { + val context = LocalContext.current + val permissionState = rememberAppPermissionState() + + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + ElevatedButton( + onClick = { + if (permissionState.allRequiredGranted()) { + viewModel.scheduleOneMinuteAlarm( + context + ) + } else { + permissionState.requestPermission() + } + } + ) { + Text("Ane click karo have") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt b/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt new file mode 100644 index 0000000..85a01f9 --- /dev/null +++ b/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt @@ -0,0 +1,32 @@ +package com.meticha.triggerxexample.home + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import com.meticha.triggerx.TriggerXAlarmScheduler +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Calendar +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val appAlarmManager: TriggerXAlarmScheduler +) : ViewModel() { + + companion object { + private const val TAG = "HomeViewModel" + } + + fun scheduleOneMinuteAlarm(context: Context): Boolean { + val triggerTime = Calendar.getInstance().apply { + add(Calendar.MINUTE, 1) + }.timeInMillis + + Log.i(TAG, "Attempting to schedule alarm for 1 minute from now ($triggerTime)") + return appAlarmManager.scheduleAlarm(context, triggerTime) + } + + fun cancelCurrentAlarm(context: Context) { + appAlarmManager.cancelAlarm(context) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..16c355d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0337a9d..9e55a44 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.12.0-alpha03" -kotlin = "2.0.21" +kotlin = "2.1.20" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -8,6 +8,12 @@ espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.9.0" activityCompose = "1.10.1" composeBom = "2024.09.00" +appcompat = "1.6.1" +material = "1.10.0" +kotlinSerialization = "2.1.21" +hiltNavigation = "1.2.0" +hilt = "2.56.2" +ksp = "2.1.20-2.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,9 +30,19 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } +jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ff83eb..674f6a0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,7 +18,8 @@ dependencyResolutionManagement { mavenCentral() } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") -rootProject.name = "TriggerX Example" +rootProject.name = "TriggerX-Example" include(":app") - \ No newline at end of file +include(":triggerx") diff --git a/triggerx/.gitignore b/triggerx/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/triggerx/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/triggerx/build.gradle.kts b/triggerx/build.gradle.kts new file mode 100644 index 0000000..18e0cf5 --- /dev/null +++ b/triggerx/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.jetbrains.kotlin.serialization) +} + +android { + namespace = "com.meticha.triggerx" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + compose = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + // hilt + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + // compose dependencies + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) +} \ No newline at end of file diff --git a/triggerx/consumer-rules.pro b/triggerx/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/triggerx/proguard-rules.pro b/triggerx/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/triggerx/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/triggerx/src/androidTest/java/com/meticha/triggerx/ExampleInstrumentedTest.kt b/triggerx/src/androidTest/java/com/meticha/triggerx/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..90abd26 --- /dev/null +++ b/triggerx/src/androidTest/java/com/meticha/triggerx/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.meticha.triggerx + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.meticha.triggerx.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/triggerx/src/main/AndroidManifest.xml b/triggerx/src/main/AndroidManifest.xml new file mode 100644 index 0000000..70b393a --- /dev/null +++ b/triggerx/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt new file mode 100644 index 0000000..f206d00 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt @@ -0,0 +1,42 @@ +package com.meticha.triggerx + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp + +class TriggerActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Flags to show activity over lock screen and turn screen on + setShowWhenLocked(true) + setTurnScreenOn(true) + + // Optional: If you want to explicitly dismiss the keyguard +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { +// val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager +// keyguardManager.requestDismissKeyguard(this, null) +// } + + setContent { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "ALARM!", + fontSize = 50.sp, + color = Color.Red + ) + } + } + } +} diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt new file mode 100644 index 0000000..f6312ef --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt @@ -0,0 +1,83 @@ +package com.meticha.triggerx + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.getSystemService +import com.meticha.triggerx.receivers.TriggerXAlarmReceiver +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.jvm.java + +@Singleton +class TriggerXAlarmScheduler @Inject constructor() { + companion object { + const val ALARM_REQUEST_CODE = 1001 + private const val TAG = "AppAlarmManager" + } + + fun scheduleAlarm(context: Context, triggerAtMillis: Long): Boolean { + val alarmManager = context.getSystemService()!! + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!alarmManager.canScheduleExactAlarms()) { + Log.e(TAG, "Cannot schedule exact alarms. Permission not granted.") + // Optionally, redirect to settings or notify user. + // For now, returning false as the HomeScreen should handle permission request. + return false + } + } + + val intent = Intent(context, TriggerXAlarmReceiver::class.java).apply { + action = TriggerXAlarmReceiver.ALARM_ACTION + } + val pendingIntent = PendingIntent.getBroadcast( + context, + ALARM_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + try { + // Use setExactAndAllowWhileIdle for alarms that need to fire even in Doze mode. + // For AlarmClock behavior (shows in status bar), use AlarmManager.AlarmClockInfo + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ) + Log.i(TAG, "Alarm scheduled for $triggerAtMillis with action: ${intent.action}") + return true + } catch (se: SecurityException) { + Log.e(TAG, "SecurityException while scheduling alarm. Check permissions.", se) + return false + } catch (e: Exception) { + Log.e(TAG, "Exception while scheduling alarm", e) + return false + } + } + + fun cancelAlarm(context: Context) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, TriggerXAlarmReceiver::class.java).apply { + action = TriggerXAlarmReceiver.ALARM_ACTION + } + val pendingIntent = PendingIntent.getBroadcast( + context, + ALARM_REQUEST_CODE, + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + + if (pendingIntent != null) { + alarmManager.cancel(pendingIntent) + pendingIntent.cancel() + Log.i(TAG, "Alarm cancelled for action: ${intent.action}") + } else { + Log.i(TAG, "Alarm not found to cancel for action: ${intent.action}") + } + } +} \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt new file mode 100644 index 0000000..fb3f64b --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -0,0 +1,144 @@ +package com.meticha.triggerx.permission + +import android.annotation.SuppressLint +import android.app.Activity +import android.util.Log +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.meticha.triggerx.permission.AlarmPermissionManager.isGranted +import java.lang.ref.WeakReference + + +@SuppressLint("ComposableNaming") +@Composable +fun rememberAppPermissionState(): PermissionState { + val permissions = listOf( + PermissionType.ALARM, + PermissionType.OVERLAY, + PermissionType.BATTERY_OPTIMIZATION + ) + + val context = LocalContext.current + + val permissionState = remember(permissions) { PermissionState(permissions) } + + // Provide context access through composable scope + permissionState.contextRef = WeakReference(context) + + + // Handle lifecycle events + PermissionLifeCycleCheckEffect( + permissionState = permissionState + ) + + // Set up permission launcher + permissionState.launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + when { + isGranted(context, permissionState.currentPermission!!) -> { + permissionState.allRequiredGranted() + permissionState.next() + } + + else -> handlePermissionDenial(permissionState) + } + } + + // Display permission rationale popup if needed + permissionState.currentPermission?.let { permission -> + when { + permissionState.showRationalePopUp -> { + ShowPopup( + message = "Bhai api do have permission", + onConfirm = { + permissionState.showRationalePopUp = false + permissionState.requestPermission() + }, + onDismiss = { + permissionState.showRationalePopUp = false + } + ) + } + } + } + + return permissionState +} + + +/** + * Handles permission denial, showing appropriate UI based on denial context + */ +private fun handlePermissionDenial(permissionState: PermissionState) { + permissionState.showRationalePopUp = true +// permissionState.showSettingsPopUp = true +} + + +@Composable +fun PermissionLifeCycleCheckEffect( + permissionState: PermissionState, + lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME +) { + + val observer = LifecycleEventObserver { _, event -> + Log.e("COMPOSE PERMISSION", "Event: $event") + if (event == lifecycleEvent) { + if (permissionState.resumedFromSettings) { + permissionState.requestPermission() + permissionState.resumedFromSettings = false + } + } + } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} + +@Composable +fun ShowPopup( + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { + Text(text = "Permission") + }, + text = { + Text(text = message) + }, + dismissButton = { + Button(onClick = { + onDismiss() + }) { + Text(text = "Cancel") + } + }, + confirmButton = { + Button(onClick = { + onConfirm() + }) { + Text(text = "OK") + } + } + ) +} \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt new file mode 100644 index 0000000..a47ef5e --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt @@ -0,0 +1,157 @@ +package com.meticha.triggerx.permission + +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import java.lang.ref.WeakReference + + +object AlarmPermissionManager { + fun hasExactAlarmPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() + } else { + true // On older versions, this permission is implicitly granted + } + } + + fun hasOverlayPermission(context: Context): Boolean { + return Settings.canDrawOverlays(context) + } + + fun hasBatteryOptimizationPermission(context: Context): Boolean { + val powerManager = + context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager + val packageName = context.packageName + return powerManager?.isIgnoringBatteryOptimizations(packageName) ?: false + } + + fun isGranted(context: Context, permission: PermissionType): Boolean { + return when (permission) { + PermissionType.ALARM -> hasExactAlarmPermission(context) + PermissionType.OVERLAY -> hasOverlayPermission(context) + PermissionType.BATTERY_OPTIMIZATION -> hasBatteryOptimizationPermission(context) + } + } + + fun createPermissionIntent(context: Context, permissionType: PermissionType): Intent { + when (permissionType) { + PermissionType.ALARM -> { + return Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = "package:${context.packageName}".toUri() + } + } + + PermissionType.OVERLAY -> { + return Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply { + data = "package:${context.packageName}".toUri() + } + } + + PermissionType.BATTERY_OPTIMIZATION -> { + return Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = "package:${context.packageName}".toUri() + } + } + } + + } + +} + + +enum class PermissionType { ALARM, OVERLAY, BATTERY_OPTIMIZATION } + +/** + * Manages the state of permission requests and their UI flows + */ +class PermissionState( + permissionList: List, +) { + // WeakReference to context to avoid memory leaks + lateinit var contextRef: WeakReference + + // All permissions that need to be handled + private val allPermissions = mutableStateListOf() + + // Permissions waiting to be processed + private var pendingPermissions = mutableStateListOf() + + // Currently processing permission + internal var currentPermission by mutableStateOf(null) + + // UI state + internal var showRationalePopUp by mutableStateOf(false) + internal var resumedFromSettings by mutableStateOf(false) + + // Permission states + private var isRequiredPermissionGranted by mutableStateOf(false) + + // Permission request launcher + internal var launcher: ManagedActivityResultLauncher? = null + + init { + allPermissions.addAll(permissionList) + pendingPermissions.addAll(permissionList) + } + + /** + * Checks if all required permissions are actually granted + */ + fun allRequiredGranted(): Boolean { + isRequiredPermissionGranted = allPermissions + .all { isGranted(it) } + return isRequiredPermissionGranted + } + + /** + * Checks if a specific permission is granted + */ + fun isGranted(permission: PermissionType): Boolean { + val context = requireNotNull(contextRef.get()) + return AlarmPermissionManager.isGranted(context, permission) + } + + /** + * Starts or continues the permission request flow + */ + fun requestPermission() { + if (pendingPermissions.isNotEmpty()) { + currentPermission = pendingPermissions.first() + currentPermission?.let { permission -> + if (isGranted(permission)) { + next() // Permission already granted, move to next + } else { + launcher?.launch( + AlarmPermissionManager.createPermissionIntent( + requireNotNull(contextRef.get()), + permission + ) + ) + } + } + } + } + + /** + * Moves to the next permission in the queue + */ + internal fun next() { + if (pendingPermissions.isNotEmpty()) { + pendingPermissions.removeAt(0) + } + + requestPermission() + } + +} + diff --git a/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt new file mode 100644 index 0000000..900cedb --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt @@ -0,0 +1,48 @@ +package com.meticha.triggerx.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import android.util.Log +import com.meticha.triggerx.TriggerActivity +import kotlin.jvm.java + +class TriggerXAlarmReceiver : BroadcastReceiver() { + + companion object { + const val ALARM_ACTION = "com.meticha.triggerx.ALARM_ACTION" + private const val TAG = "AlarmReceiver" + } + + override fun onReceive(context: Context, intent: Intent?) { + Log.d(TAG, "Alarm received!") + + // Acquire a wake lock to ensure the device doesn't sleep while processing the alarm + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "triggerx::AlarmWakeLock" + ) + wakeLock.acquire(10 * 60 * 1000L /*10 minutes - adjust as needed*/) + + try { + if (intent?.action == ALARM_ACTION) { + Log.d(TAG, "Starting TriggerActivity") + val activityIntent = Intent(context, TriggerActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + context.startActivity(activityIntent) + } else { + Log.w(TAG, "Received intent with unknown action: ${intent?.action}") + } + } catch (e: Exception) { + Log.e(TAG, "Error processing alarm", e) + } finally { + wakeLock.release() + Log.d(TAG, "WakeLock released") + } + } + + +} \ No newline at end of file diff --git a/triggerx/src/test/java/com/meticha/triggerx/ExampleUnitTest.kt b/triggerx/src/test/java/com/meticha/triggerx/ExampleUnitTest.kt new file mode 100644 index 0000000..e6b9d36 --- /dev/null +++ b/triggerx/src/test/java/com/meticha/triggerx/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.meticha.triggerx + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file From 93f9c31f307cbcfc4dafa16b6f21cf83c062096a Mon Sep 17 00:00:00 2001 From: Bhavik Date: Wed, 4 Jun 2025 12:47:50 +0530 Subject: [PATCH 2/6] Feat: Add Lock Screen Permission for Xiaomi Devices This commit introduces support for requesting the "Show on Lock Screen" permission, specifically for Xiaomi devices. This permission is often required for apps that need to display information or trigger actions while the device is locked. Key changes: - **TriggerXPermissionManager.kt**: - Added `isShowOnLockScreenPermissionEnable()`: Checks if the "Show on Lock Screen" permission is granted for Xiaomi devices using `AppOpsManager`. - Updated `isGranted()`: Now includes a check for `PermissionType.LOCK_SCREEN`. - Updated `getPermissionIntent()`: Provides the specific intent to navigate users to the permission settings screen for "Show on Lock Screen" on Xiaomi devices. - Added `LOCK_SCREEN` to the `PermissionType` enum. - **TriggerXPermissionComposable.kt**: - Modified `rememberAppPermissionState()`: Conditionally adds `PermissionType.LOCK_SCREEN` to the list of permissions to be checked if the device manufacturer is "Xiaomi". - Minor formatting adjustments. This enhancement ensures that applications using the TriggerX library can correctly prompt users for necessary lock screen permissions on Xiaomi devices, improving the reliability of features that depend on this capability. --- .../TriggerXPermissionComposable.kt | 30 ++++++++------ .../permission/TriggerXPermissionManager.kt | 39 ++++++++++++++++++- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt index fb3f64b..00db870 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -1,9 +1,8 @@ package com.meticha.triggerx.permission import android.annotation.SuppressLint -import android.app.Activity +import android.os.Build import android.util.Log -import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.AlertDialog @@ -13,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.core.app.ActivityCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -24,11 +22,19 @@ import java.lang.ref.WeakReference @SuppressLint("ComposableNaming") @Composable fun rememberAppPermissionState(): PermissionState { - val permissions = listOf( - PermissionType.ALARM, - PermissionType.OVERLAY, - PermissionType.BATTERY_OPTIMIZATION - ) + + val permissions = buildList { + addAll( + listOf( + PermissionType.ALARM, + PermissionType.OVERLAY, + PermissionType.BATTERY_OPTIMIZATION + ) + ) + if (Build.MANUFACTURER.equals("Xiaomi", true)) { + add(PermissionType.LOCK_SCREEN) + } + } val context = LocalContext.current @@ -53,7 +59,9 @@ fun rememberAppPermissionState(): PermissionState { permissionState.next() } - else -> handlePermissionDenial(permissionState) + else -> handlePermissionDenial( + permissionState + ) } } @@ -91,7 +99,7 @@ private fun handlePermissionDenial(permissionState: PermissionState) { @Composable fun PermissionLifeCycleCheckEffect( permissionState: PermissionState, - lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME + lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME, ) { val observer = LifecycleEventObserver { _, event -> @@ -116,7 +124,7 @@ fun PermissionLifeCycleCheckEffect( fun ShowPopup( message: String, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = { onDismiss() }, diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt index a47ef5e..f53e0c3 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt @@ -1,8 +1,12 @@ package com.meticha.triggerx.permission +import android.annotation.SuppressLint import android.app.AlarmManager +import android.app.AppOpsManager import android.content.Context +import android.content.Context.APP_OPS_SERVICE import android.content.Intent +import android.os.Binder import android.os.Build import android.provider.Settings import androidx.activity.compose.ManagedActivityResultLauncher @@ -13,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toUri import java.lang.ref.WeakReference +import java.lang.reflect.Method object AlarmPermissionManager { @@ -32,7 +37,26 @@ object AlarmPermissionManager { val powerManager = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager val packageName = context.packageName - return powerManager?.isIgnoringBatteryOptimizations(packageName) ?: false + return powerManager.isIgnoringBatteryOptimizations(packageName) + } + + @SuppressLint("DiscouragedPrivateApi") + // TODO("Need to check in future") + fun isShowOnLockScreenPermissionEnable(context: Context): Boolean { + return try { + val manager = context.getSystemService(APP_OPS_SERVICE) as AppOpsManager + val method: Method = AppOpsManager::class.java.getDeclaredMethod( + "checkOpNoThrow", + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java + ) + val result = + method.invoke(manager, 10020, Binder.getCallingUid(), context.packageName) as Int + AppOpsManager.MODE_ALLOWED == result + } catch (_: Exception) { + false + } } fun isGranted(context: Context, permission: PermissionType): Boolean { @@ -40,6 +64,7 @@ object AlarmPermissionManager { PermissionType.ALARM -> hasExactAlarmPermission(context) PermissionType.OVERLAY -> hasOverlayPermission(context) PermissionType.BATTERY_OPTIMIZATION -> hasBatteryOptimizationPermission(context) + PermissionType.LOCK_SCREEN -> isShowOnLockScreenPermissionEnable(context) } } @@ -62,6 +87,16 @@ object AlarmPermissionManager { data = "package:${context.packageName}".toUri() } } + + PermissionType.LOCK_SCREEN -> { + return Intent("miui.intent.action.APP_PERM_EDITOR").apply { + setClassName( + "com.miui.securitycenter", + "com.miui.permcenter.permissions.PermissionsEditorActivity" + ) + putExtra("extra_pkgname", context.packageName) + } + } } } @@ -69,7 +104,7 @@ object AlarmPermissionManager { } -enum class PermissionType { ALARM, OVERLAY, BATTERY_OPTIMIZATION } +enum class PermissionType { ALARM, OVERLAY, BATTERY_OPTIMIZATION, LOCK_SCREEN } /** * Manages the state of permission requests and their UI flows From e598cf0b758e36a1f20fca49fa8f8c7530a10a99 Mon Sep 17 00:00:00 2001 From: Bhavik Date: Wed, 4 Jun 2025 12:51:33 +0530 Subject: [PATCH 3/6] Refactor: Remove Unnecessary Whitespace in TriggerXPermissionComposable This commit removes extra whitespace in the `TriggerXPermissionComposable.kt` file. Key changes: - **triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt**: - Removed extraneous whitespace in an `else` branch of a `when` statement. This change improves code formatting and readability without affecting functionality. --- .../meticha/triggerx/permission/TriggerXPermissionComposable.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt index 00db870..0441f7b 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -59,7 +59,7 @@ fun rememberAppPermissionState(): PermissionState { permissionState.next() } - else -> handlePermissionDenial( + else -> handlePermissionDenial( permissionState ) } From 984ecac5d4dd1582f1eb7013ae91cab4810767a6 Mon Sep 17 00:00:00 2001 From: Cavin Date: Wed, 4 Jun 2025 14:41:26 +0530 Subject: [PATCH 4/6] Feat: Launch TriggerActivity via ForegroundService This commit modifies the alarm handling mechanism to launch `TriggerActivity` via a `TriggerXForegroundService`. This approach aims to improve reliability, especially on devices with aggressive background restrictions. Key changes: - **TriggerXAlarmReceiver.kt**: - When an alarm is received with `ALARM_ACTION`, it now starts `TriggerXForegroundService` instead of directly launching `TriggerActivity`. - Removed direct WakeLock handling as this will be managed by the foreground service. - **TriggerXForegroundService.kt (New File)**: - A new `Service` that acquires a `PowerManager.PARTIAL_WAKE_LOCK` upon starting. - In `onStartCommand`, if the intent action is `ALARM_ACTION`, it launches `TriggerActivity` with `FLAG_ACTIVITY_NEW_TASK` and `FLAG_ACTIVITY_CLEAR_TOP`. - The service stops itself (`stopSelf()`) after launching the activity or if the intent action is unknown. - The WakeLock is acquired with a 1-minute timeout and released in a `finally` block to ensure it's always released. - Returns `START_NOT_STICKY`. - **TriggerActivity.kt**: - Updated screen-on and show-when-locked logic: - For Android O_MR1 (API 27) and above, uses `setShowWhenLocked(true)` and `setTurnScreenOn(true)`. - For older versions (specifically API 26), uses deprecated window flags: `FLAG_SHOW_WHEN_LOCKED`, `FLAG_TURN_SCREEN_ON`, `FLAG_KEEP_SCREEN_ON`, and `FLAG_DISMISS_KEYGUARD`. - **triggerx/src/main/AndroidManifest.xml**: - Added declaration for the new `TriggerXForegroundService` with `foregroundServiceType="shortService"`. - Removed `android.permission.POST_NOTIFICATIONS` as it's not directly used by the foreground service for this alarm trigger mechanism. - **IDE Configuration**: - Added `.idea/git_toolbox_prj.xml` for GitToolBox plugin settings, enabling commit message validation. This change delegates the responsibility of waking the device and showing the activity over the lock screen to a foreground service, which is generally more resilient to system optimizations. --- .idea/git_toolbox_prj.xml | 15 ++++ triggerx/src/main/AndroidManifest.xml | 7 +- .../com/meticha/triggerx/TriggerActivity.kt | 23 ++++-- .../receivers/TriggerXAlarmReceiver.kt | 30 ++------ .../services/TriggerXForegroundService.kt | 74 +++++++++++++++++++ 5 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 .idea/git_toolbox_prj.xml create mode 100644 triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/triggerx/src/main/AndroidManifest.xml b/triggerx/src/main/AndroidManifest.xml index 70b393a..68c264a 100644 --- a/triggerx/src/main/AndroidManifest.xml +++ b/triggerx/src/main/AndroidManifest.xml @@ -5,8 +5,6 @@ - - @@ -30,6 +28,11 @@ + + diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt index f206d00..fffaeb9 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt @@ -1,6 +1,8 @@ package com.meticha.triggerx +import android.os.Build import android.os.Bundle +import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box @@ -17,14 +19,19 @@ class TriggerActivity : ComponentActivity() { super.onCreate(savedInstanceState) // Flags to show activity over lock screen and turn screen on - setShowWhenLocked(true) - setTurnScreenOn(true) - - // Optional: If you want to explicitly dismiss the keyguard -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { -// val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager -// keyguardManager.requestDismissKeyguard(this, null) -// } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + // These flags are older but necessary for API 26 + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } setContent { Box( diff --git a/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt index 900cedb..588d5ae 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt @@ -1,11 +1,13 @@ package com.meticha.triggerx.receivers +import android.R.attr.action import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.PowerManager import android.util.Log import com.meticha.triggerx.TriggerActivity +import com.meticha.triggerx.services.TriggerXForegroundService import kotlin.jvm.java class TriggerXAlarmReceiver : BroadcastReceiver() { @@ -18,29 +20,13 @@ class TriggerXAlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { Log.d(TAG, "Alarm received!") - // Acquire a wake lock to ensure the device doesn't sleep while processing the alarm - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - val wakeLock = powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "triggerx::AlarmWakeLock" - ) - wakeLock.acquire(10 * 60 * 1000L /*10 minutes - adjust as needed*/) - - try { - if (intent?.action == ALARM_ACTION) { - Log.d(TAG, "Starting TriggerActivity") - val activityIntent = Intent(context, TriggerActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - context.startActivity(activityIntent) - } else { - Log.w(TAG, "Received intent with unknown action: ${intent?.action}") + if (intent?.action == ALARM_ACTION) { + val serviceIntent = Intent(context, TriggerXForegroundService::class.java).also { + it.action = ALARM_ACTION } - } catch (e: Exception) { - Log.e(TAG, "Error processing alarm", e) - } finally { - wakeLock.release() - Log.d(TAG, "WakeLock released") + context.startForegroundService(serviceIntent) + } else { + Log.w(TAG, "Received intent with unknown action: ${intent?.action}") } } diff --git a/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt new file mode 100644 index 0000000..6f73007 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt @@ -0,0 +1,74 @@ +package com.meticha.triggerx.services + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import com.meticha.triggerx.TriggerActivity +import com.meticha.triggerx.receivers.TriggerXAlarmReceiver.Companion.ALARM_ACTION + +class TriggerXForegroundService : Service() { + + override fun onBind(p0: Intent?): IBinder? = null + + companion object { + private const val TAG = "TriggerX Foreground Service" + } + + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Acquire a wake lock to ensure the device doesn't sleep while processing the alarm + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "triggerx::AlarmWakeLock" + ) + // Acquire with a timeout, e.g., 1 minute. + // The wakelock will be released in the finally block regardless of timeout. + wakeLock.acquire(1 * 60 * 1000L /*1 minute*/) + Log.d(TAG, "WakeLock acquired") + + try { + if (intent?.action == ALARM_ACTION) { + val activityIntent = Intent(this, TriggerActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + startActivity(activityIntent) + Log.d(TAG, "TriggerActivity started") + stopSelf() + Log.d(TAG, "stopSelf() called") + } else { + Log.w(TAG, "Received intent with unknown or missing action: ${intent?.action}") + // If the service is started with an unknown action, + // it should probably stop itself too. + stopSelf() + Log.d(TAG, "stopSelf() called due to unknown action") + } + } catch (e: Exception) { + Log.e(TAG, "Error processing alarm", e) + // Even if there's an error, try to stop the service. + // The finally block will still run. + stopSelf() + Log.d(TAG, "stopSelf() called after exception") + } finally { + if (wakeLock.isHeld) { + wakeLock.release() + Log.d(TAG, "WakeLock released") + } else { + Log.d(TAG, "WakeLock was not held at finally block.") + } + } + /** + * START_NOT_STICKY: If the service is killed by the system, + * it will not be automatically restarted. + */ + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "Service destroyed") + } +} From 8a7a34f7e0b19da770852874d7efc8f8a8d2c526 Mon Sep 17 00:00:00 2001 From: Cavin Date: Wed, 4 Jun 2025 17:32:51 +0530 Subject: [PATCH 5/6] Feat: Introduce Configurable Logger for TriggerX Library This commit introduces a configurable logging mechanism for the `triggerx` library, allowing users to provide their own logging implementation. It also updates existing logging calls to use this new system and refines some UI text. Key changes: - **Logger Implementation (`triggerx/src/main/java/com/meticha/triggerx/logger`)**: - `TriggerXLogger.kt`: Defines an interface for logging (d, e, i, w methods). - `DefaultTriggerXLogger.kt`: Provides a default implementation of `TriggerXLogger` using `android.util.Log`. - `TriggerXLoggerConfig.kt`: - Introduces `LoggerConfig` object to hold the current logger instance and a default `TAG`. - Provides `setLogger()` function to allow users to set a custom `TriggerXLogger` implementation. - **Integration with Existing Components**: - `TriggerXAlarmScheduler.kt`: Replaced `android.util.Log` calls with `LoggerConfig.logger`. - `TriggerXForegroundService.kt`: Replaced `android.util.Log` calls with `LoggerConfig.logger` and removed the local `TAG` constant. - `TriggerXAlarmReceiver.kt`: Replaced `android.util.Log` calls with `LoggerConfig.logger` and removed the local `TAG` constant. - `TriggerXPermissionComposable.kt`: - Replaced `android.util.Log` call in `PermissionLifeCycleCheckEffect` with `LoggerConfig.logger`. - Updated rationale popup message from "Bhai api do have permission" to "Permissions are required to proceed further". - Changed the confirm button text in `ShowPopup` from "OK" to "Grant". - Removed a commented-out line related to `showSettingsPopUp`. This change enhances the flexibility of the TriggerX library by allowing developers to integrate their preferred logging solutions, improving debuggability and maintainability. --- .../triggerx/TriggerXAlarmScheduler.kt | 19 +++++++------- .../triggerx/logger/DefaultTriggerXLogger.kt | 21 +++++++++++++++ .../meticha/triggerx/logger/TriggerXLogger.kt | 8 ++++++ .../triggerx/logger/TriggerXLoggerConfig.kt | 13 ++++++++++ .../TriggerXPermissionComposable.kt | 8 +++--- .../receivers/TriggerXAlarmReceiver.kt | 11 +++----- .../services/TriggerXForegroundService.kt | 26 ++++++++----------- 7 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLogger.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt index f6312ef..e8d6807 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt @@ -5,18 +5,16 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import android.util.Log import androidx.core.content.getSystemService +import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.receivers.TriggerXAlarmReceiver import javax.inject.Inject import javax.inject.Singleton -import kotlin.jvm.java @Singleton class TriggerXAlarmScheduler @Inject constructor() { companion object { const val ALARM_REQUEST_CODE = 1001 - private const val TAG = "AppAlarmManager" } fun scheduleAlarm(context: Context, triggerAtMillis: Long): Boolean { @@ -24,7 +22,7 @@ class TriggerXAlarmScheduler @Inject constructor() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (!alarmManager.canScheduleExactAlarms()) { - Log.e(TAG, "Cannot schedule exact alarms. Permission not granted.") + LoggerConfig.logger.e("Cannot schedule exact alarms. Permission not granted.") // Optionally, redirect to settings or notify user. // For now, returning false as the HomeScreen should handle permission request. return false @@ -49,13 +47,16 @@ class TriggerXAlarmScheduler @Inject constructor() { triggerAtMillis, pendingIntent ) - Log.i(TAG, "Alarm scheduled for $triggerAtMillis with action: ${intent.action}") + LoggerConfig.logger.i("Alarm scheduled for $triggerAtMillis with action: ${intent.action}") return true } catch (se: SecurityException) { - Log.e(TAG, "SecurityException while scheduling alarm. Check permissions.", se) + LoggerConfig.logger.e( + "SecurityException while scheduling alarm. Check permissions.", + se + ) return false } catch (e: Exception) { - Log.e(TAG, "Exception while scheduling alarm", e) + LoggerConfig.logger.e("Exception while scheduling alarm", e) return false } } @@ -75,9 +76,9 @@ class TriggerXAlarmScheduler @Inject constructor() { if (pendingIntent != null) { alarmManager.cancel(pendingIntent) pendingIntent.cancel() - Log.i(TAG, "Alarm cancelled for action: ${intent.action}") + LoggerConfig.logger.i("Alarm cancelled for action: ${intent.action}") } else { - Log.i(TAG, "Alarm not found to cancel for action: ${intent.action}") + LoggerConfig.logger.i("Alarm not found to cancel for action: ${intent.action}") } } } \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt b/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt new file mode 100644 index 0000000..56cd37e --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt @@ -0,0 +1,21 @@ +package com.meticha.triggerx.logger + +import android.util.Log + +object DefaultTriggerXLogger : TriggerXLogger { + override fun d(message: String) { + Log.d(LoggerConfig.TAG, message) + } + + override fun e(message: String, throwable: Throwable?) { + Log.e(LoggerConfig.TAG, message, throwable) + } + + override fun i(message: String) { + Log.i(LoggerConfig.TAG, message) + } + + override fun w(message: String) { + Log.w(LoggerConfig.TAG, message) + } +} \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLogger.kt b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLogger.kt new file mode 100644 index 0000000..eb4b0aa --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLogger.kt @@ -0,0 +1,8 @@ +package com.meticha.triggerx.logger + +interface TriggerXLogger { + fun d(message: String) + fun e(message: String, throwable: Throwable? = null) + fun i(message: String) + fun w(message: String) +} \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt new file mode 100644 index 0000000..d30740e --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt @@ -0,0 +1,13 @@ +package com.meticha.triggerx.logger + +object LoggerConfig { + const val TAG = "TRIGGERX" + + var logger: TriggerXLogger = DefaultTriggerXLogger + + + + fun setLogger(customLogger: TriggerXLogger) { + logger = customLogger + } +} \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt index 0441f7b..70eaf34 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.permission.AlarmPermissionManager.isGranted import java.lang.ref.WeakReference @@ -70,7 +71,7 @@ fun rememberAppPermissionState(): PermissionState { when { permissionState.showRationalePopUp -> { ShowPopup( - message = "Bhai api do have permission", + message = "Permissions are required to proceed further", onConfirm = { permissionState.showRationalePopUp = false permissionState.requestPermission() @@ -92,7 +93,6 @@ fun rememberAppPermissionState(): PermissionState { */ private fun handlePermissionDenial(permissionState: PermissionState) { permissionState.showRationalePopUp = true -// permissionState.showSettingsPopUp = true } @@ -103,7 +103,7 @@ fun PermissionLifeCycleCheckEffect( ) { val observer = LifecycleEventObserver { _, event -> - Log.e("COMPOSE PERMISSION", "Event: $event") + LoggerConfig.logger.d("Event: $event") if (event == lifecycleEvent) { if (permissionState.resumedFromSettings) { permissionState.requestPermission() @@ -145,7 +145,7 @@ fun ShowPopup( Button(onClick = { onConfirm() }) { - Text(text = "OK") + Text(text = "Grant") } } ) diff --git a/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt index 588d5ae..d1e9b9e 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt @@ -1,24 +1,19 @@ package com.meticha.triggerx.receivers -import android.R.attr.action import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.PowerManager -import android.util.Log -import com.meticha.triggerx.TriggerActivity +import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.services.TriggerXForegroundService -import kotlin.jvm.java class TriggerXAlarmReceiver : BroadcastReceiver() { companion object { const val ALARM_ACTION = "com.meticha.triggerx.ALARM_ACTION" - private const val TAG = "AlarmReceiver" } override fun onReceive(context: Context, intent: Intent?) { - Log.d(TAG, "Alarm received!") + LoggerConfig.logger.d("Alarm received!") if (intent?.action == ALARM_ACTION) { val serviceIntent = Intent(context, TriggerXForegroundService::class.java).also { @@ -26,7 +21,7 @@ class TriggerXAlarmReceiver : BroadcastReceiver() { } context.startForegroundService(serviceIntent) } else { - Log.w(TAG, "Received intent with unknown action: ${intent?.action}") + LoggerConfig.logger.w("Received intent with unknown action: ${intent?.action}") } } diff --git a/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt index 6f73007..0681bc5 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt @@ -5,18 +5,14 @@ import android.content.Context import android.content.Intent import android.os.IBinder import android.os.PowerManager -import android.util.Log import com.meticha.triggerx.TriggerActivity +import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.receivers.TriggerXAlarmReceiver.Companion.ALARM_ACTION class TriggerXForegroundService : Service() { override fun onBind(p0: Intent?): IBinder? = null - companion object { - private const val TAG = "TriggerX Foreground Service" - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Acquire a wake lock to ensure the device doesn't sleep while processing the alarm @@ -28,7 +24,7 @@ class TriggerXForegroundService : Service() { // Acquire with a timeout, e.g., 1 minute. // The wakelock will be released in the finally block regardless of timeout. wakeLock.acquire(1 * 60 * 1000L /*1 minute*/) - Log.d(TAG, "WakeLock acquired") + LoggerConfig.logger.d("WakeLock acquired") try { if (intent?.action == ALARM_ACTION) { @@ -36,28 +32,28 @@ class TriggerXForegroundService : Service() { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } startActivity(activityIntent) - Log.d(TAG, "TriggerActivity started") + LoggerConfig.logger.d("TriggerActivity started") stopSelf() - Log.d(TAG, "stopSelf() called") + LoggerConfig.logger.d("stopSelf() called") } else { - Log.w(TAG, "Received intent with unknown or missing action: ${intent?.action}") + LoggerConfig.logger.w("Received intent with unknown or missing action: ${intent?.action}") // If the service is started with an unknown action, // it should probably stop itself too. stopSelf() - Log.d(TAG, "stopSelf() called due to unknown action") + LoggerConfig.logger.d("stopSelf() called due to unknown action") } } catch (e: Exception) { - Log.e(TAG, "Error processing alarm", e) + LoggerConfig.logger.e("Error processing alarm", e) // Even if there's an error, try to stop the service. // The finally block will still run. stopSelf() - Log.d(TAG, "stopSelf() called after exception") + LoggerConfig.logger.d("stopSelf() called after exception") } finally { if (wakeLock.isHeld) { wakeLock.release() - Log.d(TAG, "WakeLock released") + LoggerConfig.logger.d("WakeLock released") } else { - Log.d(TAG, "WakeLock was not held at finally block.") + LoggerConfig.logger.d("WakeLock was not held at finally block.") } } /** @@ -69,6 +65,6 @@ class TriggerXForegroundService : Service() { override fun onDestroy() { super.onDestroy() - Log.d(TAG, "Service destroyed") + LoggerConfig.logger.d("Service destroyed") } } From 72f56b210c4e01ca429a5ce4d0780273596ec1bd Mon Sep 17 00:00:00 2001 From: Cavin Date: Wed, 4 Jun 2025 21:57:53 +0530 Subject: [PATCH 6/6] Feat: Base implementation of the whole library --- .idea/dictionaries/project.xml | 8 +++ app/src/main/AndroidManifest.xml | 20 ++++++- .../meticha/triggerxexample/MainActivity.kt | 2 + .../triggerxexample/TriggerXApplication.kt | 16 +++++- .../triggerxexample/alarm/AppAlarmActivity.kt | 27 +++++++++ .../triggerxexample/home/HomeViewModel.kt | 10 ++-- gradle/libs.versions.toml | 2 + triggerx/build.gradle.kts | 4 ++ triggerx/src/main/AndroidManifest.xml | 18 +++--- .../triggerx/DefaultTriggerActivity.kt | 26 +++++++++ ...TriggerActivity.kt => TriggerXActivity.kt} | 33 ++++------- .../triggerx/TriggerXAlarmScheduler.kt | 44 ++++++++------- .../java/com/meticha/triggerx/dsl/TriggerX.kt | 47 ++++++++++++++++ .../meticha/triggerx/dsl/TriggerXConfig.kt | 30 ++++++++++ .../triggerx/logger/TriggerXLoggerConfig.kt | 6 -- .../TriggerXPermissionComposable.kt | 4 +- .../permission/TriggerXPermissionManager.kt | 36 +++++++++--- .../triggerx/preference/TriggerXPreference.kt | 51 +++++++++++++++++ .../services/TriggerXForegroundService.kt | 56 ++++++++++++++++++- 19 files changed, 362 insertions(+), 78 deletions(-) create mode 100644 .idea/dictionaries/project.xml create mode 100644 app/src/main/java/com/meticha/triggerxexample/alarm/AppAlarmActivity.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt rename triggerx/src/main/java/com/meticha/triggerx/{TriggerActivity.kt => TriggerXActivity.kt} (51%) create mode 100644 triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerXConfig.kt create mode 100644 triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 0000000..d7c7ce8 --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,8 @@ + + + + triggerx + triggrx + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c97255..16011ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,14 @@ + + + + + + + + - + + + \ No newline at end of file diff --git a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt index 38c6e68..21498ee 100644 --- a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt +++ b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt @@ -4,6 +4,8 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.meticha.triggerx.dsl.TriggerX +import com.meticha.triggerxexample.alarm.AppAlarmActivity import com.meticha.triggerxexample.home.HomeScreen import com.meticha.triggerxexample.ui.theme.TriggerXExampleTheme import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt b/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt index e555ee4..07f3ac4 100644 --- a/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt +++ b/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt @@ -1,7 +1,21 @@ package com.meticha.triggerxexample import android.app.Application +import com.meticha.triggerx.dsl.TriggerX +import com.meticha.triggerxexample.alarm.AppAlarmActivity import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class TriggerXApplication: Application() \ No newline at end of file +class TriggerXApplication : Application() { + override fun onCreate() { + super.onCreate() + TriggerX.init(applicationContext) { + useDefaultNotification( + title = "My App Alarm", + message = "Your alarm is ringing!", + channelName = "My Awesome App Notifications" + ) + activityClass = AppAlarmActivity::class.java + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/meticha/triggerxexample/alarm/AppAlarmActivity.kt b/app/src/main/java/com/meticha/triggerxexample/alarm/AppAlarmActivity.kt new file mode 100644 index 0000000..22446b6 --- /dev/null +++ b/app/src/main/java/com/meticha/triggerxexample/alarm/AppAlarmActivity.kt @@ -0,0 +1,27 @@ +package com.meticha.triggerxexample.alarm + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import com.meticha.triggerx.TriggerXActivity + +class AppAlarmActivity : TriggerXActivity() { + @Composable + override fun AlarmContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "CAVIN!", + fontSize = 50.sp, + color = Color.Red + ) + } + } +} diff --git a/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt b/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt index 85a01f9..966c4f7 100644 --- a/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt +++ b/app/src/main/java/com/meticha/triggerxexample/home/HomeViewModel.kt @@ -9,14 +9,14 @@ import java.util.Calendar import javax.inject.Inject @HiltViewModel -class HomeViewModel @Inject constructor( - private val appAlarmManager: TriggerXAlarmScheduler -) : ViewModel() { +class HomeViewModel @Inject constructor() : ViewModel() { companion object { private const val TAG = "HomeViewModel" } + private val appAlarmManager: TriggerXAlarmScheduler = TriggerXAlarmScheduler() + fun scheduleOneMinuteAlarm(context: Context): Boolean { val triggerTime = Calendar.getInstance().apply { add(Calendar.MINUTE, 1) @@ -26,7 +26,7 @@ class HomeViewModel @Inject constructor( return appAlarmManager.scheduleAlarm(context, triggerTime) } - fun cancelCurrentAlarm(context: Context) { - appAlarmManager.cancelAlarm(context) + fun cancelCurrentAlarm(context: Context, id: Int) { + appAlarmManager.cancelAlarm(context, id) } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e55a44..8a67cc7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.12.0-alpha03" +datastorePreferences = "1.1.7" kotlin = "2.1.20" coreKtx = "1.16.0" junit = "4.13.2" @@ -17,6 +18,7 @@ ksp = "2.1.20-2.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } diff --git a/triggerx/build.gradle.kts b/triggerx/build.gradle.kts index 18e0cf5..f10abcb 100644 --- a/triggerx/build.gradle.kts +++ b/triggerx/build.gradle.kts @@ -52,6 +52,10 @@ dependencies { implementation(libs.hilt.navigation.compose) ksp(libs.hilt.compiler) + // datastore + implementation(libs.androidx.datastore.preferences) + + // compose dependencies implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/triggerx/src/main/AndroidManifest.xml b/triggerx/src/main/AndroidManifest.xml index 68c264a..68a159d 100644 --- a/triggerx/src/main/AndroidManifest.xml +++ b/triggerx/src/main/AndroidManifest.xml @@ -1,24 +1,22 @@ - - - - + - - + + - + android:turnScreenOn="true" + tools:targetApi="27" /> - - \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt new file mode 100644 index 0000000..97b0f8a --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt @@ -0,0 +1,26 @@ +package com.meticha.triggerx + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp + +class DefaultTriggerActivity : TriggerXActivity() { + @Composable + override fun AlarmContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "TRIGGERX!", + fontSize = 50.sp, + color = Color.Red + ) + } + } +} \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt similarity index 51% rename from triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt rename to triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt index fffaeb9..a78a2fe 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/TriggerActivity.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt @@ -5,25 +5,23 @@ import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.sp +import androidx.compose.runtime.Composable -class TriggerActivity : ComponentActivity() { + +abstract class TriggerXActivity : ComponentActivity() { + + /** Override this to paint your own UI. */ + @Composable + abstract fun AlarmContent() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Flags to show activity over lock screen and turn screen on + // ── Required window flags ────────────────────────────────────────── if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true) setTurnScreenOn(true) } else { - // These flags are older but necessary for API 26 @Suppress("DEPRECATION") window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or @@ -33,17 +31,8 @@ class TriggerActivity : ComponentActivity() { ) } - setContent { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "ALARM!", - fontSize = 50.sp, - color = Color.Red - ) - } - } + // ── Render developer-supplied Compose UI ────────────────────────── + setContent { AlarmContent() } } } + diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt index e8d6807..3dda0e4 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt @@ -8,16 +8,13 @@ import android.os.Build import androidx.core.content.getSystemService import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.receivers.TriggerXAlarmReceiver -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class TriggerXAlarmScheduler @Inject constructor() { - companion object { - const val ALARM_REQUEST_CODE = 1001 - } - - fun scheduleAlarm(context: Context, triggerAtMillis: Long): Boolean { +class TriggerXAlarmScheduler { + fun scheduleAlarm( + context: Context, + triggerAtMillis: Long, + alarmId: Int = (System.currentTimeMillis() % Int.MAX_VALUE).toInt() + ): Boolean { val alarmManager = context.getSystemService()!! if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -33,10 +30,10 @@ class TriggerXAlarmScheduler @Inject constructor() { action = TriggerXAlarmReceiver.ALARM_ACTION } val pendingIntent = PendingIntent.getBroadcast( - context, - ALARM_REQUEST_CODE, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + /* context = */ context, + /* requestCode = */ alarmId, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) try { @@ -47,7 +44,7 @@ class TriggerXAlarmScheduler @Inject constructor() { triggerAtMillis, pendingIntent ) - LoggerConfig.logger.i("Alarm scheduled for $triggerAtMillis with action: ${intent.action}") + LoggerConfig.logger.i("Alarm [$alarmId] scheduled for $triggerAtMillis") return true } catch (se: SecurityException) { LoggerConfig.logger.e( @@ -61,24 +58,33 @@ class TriggerXAlarmScheduler @Inject constructor() { } } - fun cancelAlarm(context: Context) { - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + fun scheduleMultipleAlarms( + context: Context, + events: List> // Pair + ): List { + return events.map { (id, time) -> + scheduleAlarm(context, time, id) + } + } + + fun cancelAlarm(context: Context, alarmId: Int) { val intent = Intent(context, TriggerXAlarmReceiver::class.java).apply { action = TriggerXAlarmReceiver.ALARM_ACTION } val pendingIntent = PendingIntent.getBroadcast( context, - ALARM_REQUEST_CODE, + alarmId, intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE ) if (pendingIntent != null) { + val alarmManager = context.getSystemService(AlarmManager::class.java) alarmManager.cancel(pendingIntent) pendingIntent.cancel() - LoggerConfig.logger.i("Alarm cancelled for action: ${intent.action}") + LoggerConfig.logger.i("Alarm [$alarmId] cancelled.") } else { - LoggerConfig.logger.i("Alarm not found to cancel for action: ${intent.action}") + LoggerConfig.logger.i("Alarm [$alarmId] not found to cancel.") } } } \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt new file mode 100644 index 0000000..faff469 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt @@ -0,0 +1,47 @@ +package com.meticha.triggerx.dsl + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import com.meticha.triggerx.logger.DefaultTriggerXLogger +import com.meticha.triggerx.logger.LoggerConfig +import com.meticha.triggerx.preference.TriggerXPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object TriggerX { + internal var config: TriggerXConfig? = null + + fun init(context: Context, configure: TriggerXConfig.() -> Unit) { + val conf = TriggerXConfig().apply(configure) + config = conf + + LoggerConfig.logger = conf.customLogger ?: DefaultTriggerXLogger + LoggerConfig.logger.d("TriggerX initialized with notification title: ${conf.notificationTitle}") + + CoroutineScope(Dispatchers.IO).launch { + TriggerXPreferences.save(context.applicationContext, conf) + } + + setupNotificationChannel(context.applicationContext) + } + + private fun setupNotificationChannel(context: Context) { + val channelName = config?.notificationChannelName ?: "TriggerX Alarms" + val channel = NotificationChannel( + DEFAULT_CHANNEL_ID, + channelName, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifications for TriggerX alarms" + } + val nm = context.getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(channel) + } + + const val DEFAULT_CHANNEL_ID = "triggerx_channel" + + fun getNotificationTitle(): String = config?.notificationTitle ?: "Alarm" + fun getNotificationMessage(): String = config?.notificationMessage ?: "Alarm is ringing" +} diff --git a/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerXConfig.kt b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerXConfig.kt new file mode 100644 index 0000000..024d5e9 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerXConfig.kt @@ -0,0 +1,30 @@ +package com.meticha.triggerx.dsl + +import android.app.Activity +import com.meticha.triggerx.DefaultTriggerActivity +import com.meticha.triggerx.logger.TriggerXLogger +import kotlin.jvm.java + +class TriggerXConfig internal constructor() { + internal var notificationTitle: String? = null + internal var notificationMessage: String? = null + internal var notificationChannelName: String = "TriggerX Alarms" + + internal var customLogger: TriggerXLogger? = null + var activityClass: Class = DefaultTriggerActivity::class.java + + + fun useDefaultNotification( + title: String, + message: String, + channelName: String = "TriggerX Alarms" + ) { + notificationTitle = title + notificationMessage = message + notificationChannelName = channelName + } + + fun logging(logger: TriggerXLogger) { + customLogger = logger + } +} diff --git a/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt index d30740e..6fef169 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt @@ -4,10 +4,4 @@ object LoggerConfig { const val TAG = "TRIGGERX" var logger: TriggerXLogger = DefaultTriggerXLogger - - - - fun setLogger(customLogger: TriggerXLogger) { - logger = customLogger - } } \ No newline at end of file diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt index 70eaf34..e5ce4bc 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -2,7 +2,6 @@ package com.meticha.triggerx.permission import android.annotation.SuppressLint import android.os.Build -import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.AlertDialog @@ -29,7 +28,8 @@ fun rememberAppPermissionState(): PermissionState { listOf( PermissionType.ALARM, PermissionType.OVERLAY, - PermissionType.BATTERY_OPTIMIZATION + PermissionType.BATTERY_OPTIMIZATION, + PermissionType.NOTIFICATION ) ) if (Build.MANUFACTURER.equals("Xiaomi", true)) { diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt index f53e0c3..6c84f21 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt @@ -6,6 +6,7 @@ import android.app.AppOpsManager import android.content.Context import android.content.Context.APP_OPS_SERVICE import android.content.Intent +import android.content.pm.PackageManager import android.os.Binder import android.os.Build import android.provider.Settings @@ -15,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat import androidx.core.net.toUri import java.lang.ref.WeakReference import java.lang.reflect.Method @@ -59,12 +61,24 @@ object AlarmPermissionManager { } } + fun isNotificationPermissionEnabled(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true // Notifications don't need explicit permission pre-Android 13 + } + } + fun isGranted(context: Context, permission: PermissionType): Boolean { return when (permission) { PermissionType.ALARM -> hasExactAlarmPermission(context) PermissionType.OVERLAY -> hasOverlayPermission(context) PermissionType.BATTERY_OPTIMIZATION -> hasBatteryOptimizationPermission(context) PermissionType.LOCK_SCREEN -> isShowOnLockScreenPermissionEnable(context) + PermissionType.NOTIFICATION -> isNotificationPermissionEnabled(context) } } @@ -91,20 +105,28 @@ object AlarmPermissionManager { PermissionType.LOCK_SCREEN -> { return Intent("miui.intent.action.APP_PERM_EDITOR").apply { setClassName( - "com.miui.securitycenter", - "com.miui.permcenter.permissions.PermissionsEditorActivity" - ) + "com.miui.securitycenter", + "com.miui.permcenter.permissions.PermissionsEditorActivity" + ) putExtra("extra_pkgname", context.packageName) } } - } - } + PermissionType.NOTIFICATION -> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + } else { + Intent() + } + } + } + } } - -enum class PermissionType { ALARM, OVERLAY, BATTERY_OPTIMIZATION, LOCK_SCREEN } +enum class PermissionType { ALARM, OVERLAY, BATTERY_OPTIMIZATION, LOCK_SCREEN, NOTIFICATION } /** * Manages the state of permission requests and their UI flows diff --git a/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt b/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt new file mode 100644 index 0000000..53e8568 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt @@ -0,0 +1,51 @@ +package com.meticha.triggerx.preference + +import android.app.Activity +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.meticha.triggerx.dsl.TriggerXConfig +import com.meticha.triggerx.logger.LoggerConfig +import kotlinx.coroutines.flow.firstOrNull + +object TriggerXPreferences { + private val Context.dataStore: DataStore by preferencesDataStore(name = "triggerx_prefs") + + private val KEY_ACTIVITY_CLASS = stringPreferencesKey("activity_class") + private val KEY_NOTIFICATION_TITLE = stringPreferencesKey("notification_title") + private val KEY_NOTIFICATION_MESSAGE = stringPreferencesKey("notification_message") + + suspend fun save(context: Context, config: TriggerXConfig) { + context.dataStore.edit { prefs -> + prefs[KEY_ACTIVITY_CLASS] = config.activityClass.name + config.notificationTitle?.let { + prefs[KEY_NOTIFICATION_TITLE] = it + } + config.notificationMessage?.let { + prefs[KEY_NOTIFICATION_MESSAGE] = it + } + } + } + + suspend fun load(context: Context): TriggerXConfig? { + val prefs = context.dataStore.data.firstOrNull() ?: return null + val className = prefs[KEY_ACTIVITY_CLASS] ?: return null + val title = prefs[KEY_NOTIFICATION_TITLE] ?: "Alarm" + val message = prefs[KEY_NOTIFICATION_MESSAGE] ?: "Alarm is ringing" + + return try { + val clazz = Class.forName(className) as Class + TriggerXConfig().apply { + activityClass = clazz + notificationTitle = title + notificationMessage = message + } + } catch (e: Exception) { + LoggerConfig.logger.e("Failed to load activity class from prefs", e) + null + } + } +} diff --git a/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt index 0681bc5..88f3201 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt @@ -1,20 +1,42 @@ package com.meticha.triggerx.services +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder import android.os.PowerManager -import com.meticha.triggerx.TriggerActivity +import androidx.core.app.NotificationCompat +import com.meticha.triggerx.DefaultTriggerActivity +import com.meticha.triggerx.dsl.TriggerX import com.meticha.triggerx.logger.LoggerConfig +import com.meticha.triggerx.preference.TriggerXPreferences import com.meticha.triggerx.receivers.TriggerXAlarmReceiver.Companion.ALARM_ACTION +import kotlinx.coroutines.runBlocking +import kotlin.jvm.java class TriggerXForegroundService : Service() { + companion object { + private const val NOTIFICATION_ID = 1001 + private const val DEFAULT_CHANNEL_ID = "triggerx_channel" + } + + override fun onBind(intent: Intent?): IBinder? = null - override fun onBind(p0: Intent?): IBinder? = null + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + + val notification = buildNotification() + startForeground(NOTIFICATION_ID, notification) + // Acquire a wake lock to ensure the device doesn't sleep while processing the alarm val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val wakeLock = powerManager.newWakeLock( @@ -28,7 +50,8 @@ class TriggerXForegroundService : Service() { try { if (intent?.action == ALARM_ACTION) { - val activityIntent = Intent(this, TriggerActivity::class.java).apply { + val activityClass = runBlocking { resolveActivityClass(applicationContext) } + val activityIntent = Intent(this, activityClass).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } startActivity(activityIntent) @@ -67,4 +90,31 @@ class TriggerXForegroundService : Service() { super.onDestroy() LoggerConfig.logger.d("Service destroyed") } + + private fun createNotificationChannel() { + val channelName = TriggerX.config?.notificationChannelName ?: "TriggerX Alarms" + val channel = NotificationChannel( + DEFAULT_CHANNEL_ID, + channelName, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifications for TriggerX alarms" + } + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel) + } + + private fun buildNotification(): Notification { + return NotificationCompat.Builder(this, DEFAULT_CHANNEL_ID) + .setContentTitle(TriggerX.getNotificationTitle()) + .setContentText(TriggerX.getNotificationMessage()) + .setOngoing(true) + .build() + } + + suspend fun resolveActivityClass(context: Context): Class { + return TriggerX.config?.activityClass + ?: TriggerXPreferences.load(context)?.activityClass + ?: DefaultTriggerActivity::class.java + } }