diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6e42327 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Meticha + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2740da4 --- /dev/null +++ b/README.md @@ -0,0 +1,364 @@ +# TriggerX + +![triggerx-banner.png](banner/triggerx-banner.png) + +TriggerX is a modular, developer-friendly **alarm execution** library for Android. + +It simplifies scheduling exact alarms and showing user-facing UIs at a specific time, even when +your app has been killed or without you managing foreground-service boilerplate, wake-locks, or +lock-screen flags. + +--- + +## πŸ“Œ What does TriggerX do? + +| | | +|-----------------------------------------------------------------------------------|----------------------------------------------------------------------| +| ⏰ **Exact alarms** that work in Doze (API 25+) | πŸ”“ **Lock-screen activity** automatically shown when the alarm fires | +| πŸ”‘ Handles permissions: exact alarm, battery optimisations, overlay, notification | πŸ“± Wakes the device, starts a foreground service, then stops it | +| πŸ”„ Fetches fresh data at alarm time via `suspend` provider (Room, API, …) | 🎨 Lets you build the UI in **Jetpack Compose** | + +Think of TriggerX as an execution layer that runs a piece of UI logic at the right time and hides +the system details. + +--- + +## βœ… When should you use TriggerX? + +| | | +|---------------------------------------------------------------------------------|-----------------------------------------------------------------| +| πŸ“… You need to show a UI (reminder, alert, action screen) **at a given time** | πŸ”„ The UI needs **live data** from DB, cache, or API | +| 🧹 You want to avoid edge-case handling for Doze, foreground services, or flags | 🎯 You want a consistent alarm solution across Android versions | + +--- + +## πŸ› οΈ Setup + +### πŸ“¦ Installation + +## πŸš€ Quick Start + +### 1. Initialize in your Application class + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + TriggerX.init(this) { + + /* UI that opens when the alarm fires */ + activityClass = MyAlarmActivity::class.java + + /* Foreground-service notification */ + useDefaultNotification( + title = "Alarm running", + message = "Tap to open", + channelName = "Alarm Notifications" + ) + + /* Optional: Provide up-to-date data right before the UI opens */ + alarmDataProvider = object : TriggerXDataProvider { + override suspend fun provideData(alarmId: Int, alarmType: String): Bundle { + return when (alarmType) { + "MEETING" -> { + val meeting = meetingRepository.getMeeting(alarmId) + return bundleOf( + "title" to meeting?.title, + "location" to meeting?.location + ) + } + + else -> bundleOf() + } + } + } + } + } +} +``` + +### 2. Ask for the permission + +The library provides a composable helper to request permissions so that you don't have to manage +this manually. However, the library provides the functionality to request permissions manually if +you want to follow that path + +```kotlin +@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("Schedule Activity") + } + } + } +} +``` + +### 3. Schedule an alarm + +```kotlin +val inFiveMinutes = Calendar.getInstance().apply { + add(Calendar.MINUTE, 5) +}.timeInMillis + + +TriggerXAlarmScheduler().scheduleAlarm( + context = this, + alarmId = 1, + alarmType = "MEETING", + triggerAtMillis = inFiveMinutes +) +``` + +πŸ’‘ You can schedule many alarms with different alarmId / alarmType. + +## 🧩 Create your Alarm UI + +```kotlin +class MyAlarmActivity : TriggerXAlarmActivity() { + + @Composable + override fun AlarmContent() { + val data = intent.getBundleExtra("ALARM_DATA") + val title = data?.getString("title") ?: "No Title" + val location = data?.getString("location") ?: "No Location" + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Text(location, fontSize = 18.sp) + Spacer(Modifier.height(32.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button(onClick = ::finish) { Text("Dismiss") } + Button(onClick = { /* Snooze */ }) { Text("Snooze") } + } + } + } +} +``` + +What the base class handles for you: + +- πŸ”“ Shows over lock-screen +- πŸ“± Turns screen on +- βš™οΈ Chooses correct flags per Android version +- πŸ“¦ Receives & parses Bundle (β€œALARM_DATA”) + +## πŸ” Permissions + +TriggerX includes a Composable helper to request what it needs. + +```kotlin +@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("Schedule Activity") + } + } + } +} +``` + +Covered automatically: + +| Permission | Version | +|--------------------------------|---------| +| ⏰ Exact alarm | API 31+ | +| πŸ”‹ Ignore battery optimisation | all | +| πŸ–Ό Overlay (only if needed) | all | +| πŸ“’ Post-notification | API 33+ | + +## 🧠 How TriggerX works + +1. AlarmManager schedules an exact alarm +2. BroadcastReceiver fires when alarm time arrives +3. ForegroundService starts (Play-Store-compliant) +4. Your AlarmDataProvider supplies fresh data (suspend) +5. Service launches your activity with the data bundle +6. ForegroundService stops; activity shows over lock-screen + +## πŸ—‚ Data Provider Interface + +```kotlin +interface TriggerXDataProvider { + /** + * Fetch or build data for a specific alarm. + * Called on a background dispatcher. + */ + suspend fun provideData(alarmId: Int, alarmType: String): Bundle +} +``` + +Room example: + +```kotlin +class RoomProvider(private val dao: MeetingDao) : TriggerXDataProvider { + override suspend fun provideData(id: Int, type: String) = when (type) { + "MEETING" -> dao.getMeeting(id)?.run { + bundleOf("title" to title, "location" to location) + } ?: Bundle.EMPTY + else -> Bundle.EMPTY + } +} +``` + +## πŸ“ TriggerX Configuration Example + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + TriggerX.init(this) { + notificationTitle = "Reminder" + notificationMessage = "You have a scheduled event" + notificationChannelName = "Event Alarms" + customLogger = object : TriggerXLogger { + override fun d(message: String) = Log.d("TriggerX", message) + override fun e(message: String, throwable: Throwable?) = + Log.e("TriggerX", message, throwable) + } + } + } +} +``` + +## πŸ”— Retrofit or Network-Based Data Provider + +```kotlin +class ApiProvider(private val api: AlarmApi) : TriggerXDataProvider { + override suspend fun provideData(alarmId: Int, alarmType: String): Bundle { + return try { + val response = api.getAlarmDetails(alarmId) + bundleOf("title" to response.title, "description" to response.description) + } catch (e: Exception) { + Bundle.EMPTY + } + } +} +``` + +## πŸ’Ύ In-Memory or Static Data Provider + +```kotlin +class StaticProvider : TriggerXDataProvider { + override suspend fun provideData(alarmId: Int, alarmType: String): Bundle { + return when (alarmType) { + "WELCOME" -> bundleOf("title" to "Welcome!", "body" to "Thanks for installing our app.") + else -> Bundle.EMPTY + } + } +} +``` + +## πŸ” Delegating to Multiple Providers + +```kotlin +class MultiSourceProvider( + private val roomProvider: TriggerXDataProvider, + private val networkProvider: TriggerXDataProvider +) : TriggerXDataProvider { + + override suspend fun provideData(alarmId: Int, alarmType: String): Bundle { + return when (alarmType) { + "MEETING" -> roomProvider.provideData(alarmId, alarmType) + "NEWS" -> networkProvider.provideData(alarmId, alarmType) + else -> Bundle.EMPTY + } + } +} +``` + +## πŸ’Ύ Persistence + +TriggerX stores minimal info (ID, type, activity class) in Preferences DataStore so alarms still +work after the user swipes the app away. + +## πŸ—‘ Cancel alarms + +```kotlin +TriggerXAlarmScheduler().cancelAlarm(this, alarmId = 1) +``` + +## πŸ—¨ Troubleshooting + +| Issue | Check | +|----------------------|---------------------------------| +| Alarm doesn’t fire | Exact-alarm permission granted? | +| Activity not visible | Overlay / lock-screen flags? | +| Service killed early | Battery optimisation disabled? | + +## 🀝 Contributing + +- Fork the repo +- Create a branch (feature/your-idea) +- Commit and open a Pull Request +- Please include tests or sample usage where possible. + +## πŸ“„ License + +```xml +Designed and developed by 2025 Meticha + + Licensed under the Apache License, Version 2.0 (the "License");you may not use this file except in compliance with the License.You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, softwaredistributed under the License is distributed on an "AS IS" BASIS,WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.See the License for the specific language governing permissions andlimitations under the License. +``` + +If TriggerX saves you time, consider giving the repo a ⭐ on GitHub. diff --git a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt index 21498ee..38c6e68 100644 --- a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt +++ b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt @@ -4,8 +4,6 @@ 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/home/HomeScreen.kt b/app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt index ec3d4cf..60f527a 100644 --- a/app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt +++ b/app/src/main/java/com/meticha/triggerxexample/home/HomeScreen.kt @@ -43,7 +43,7 @@ fun HomeScreen( } } ) { - Text("Ane click karo have") + Text("Schedule Activity") } } } diff --git a/banner/triggerx-banner.png b/banner/triggerx-banner.png new file mode 100644 index 0000000..9a0dfb8 Binary files /dev/null and b/banner/triggerx-banner.png differ diff --git a/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt index 97b0f8a..955600a 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/DefaultTriggerActivity.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp -class DefaultTriggerActivity : TriggerXActivity() { +internal class DefaultTriggerActivity : TriggerXActivity() { @Composable override fun AlarmContent() { Box( diff --git a/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt index a78a2fe..e796e09 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt @@ -7,17 +7,37 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.Composable - +/** + * Base activity for displaying alarm content. + * + * This abstract class provides the necessary setup for an activity that is launched when an alarm fires. + * It handles window flags for showing the activity over the lock screen and turning the screen on. + * + * Subclasses must implement [AlarmContent] to define the UI to be displayed. + */ abstract class TriggerXActivity : ComponentActivity() { - /** Override this to paint your own UI. */ + /** + * Override this composable function to define the UI for your alarm screen. + * This is where you will build the content that the user sees when an alarm is triggered. + */ @Composable abstract fun AlarmContent() + /** + * Called when the activity is starting. + * + * This method sets up the window flags to ensure the activity is shown over the lock screen + * and turns the screen on. It then sets the content of the activity to the [AlarmContent] + * composable. + * + * @param savedInstanceState If the activity is being re-initialized after + * previously being shut down then this Bundle contains the data it most + * recently supplied in [onSaveInstanceState]. Note: Otherwise it is null. + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // ── Required window flags ────────────────────────────────────────── if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true) setTurnScreenOn(true) @@ -30,9 +50,7 @@ abstract class TriggerXActivity : ComponentActivity() { WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD ) } - - // ── 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 af3d6eb..c9514ac 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt @@ -9,7 +9,26 @@ import androidx.core.content.getSystemService import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.receivers.TriggerXAlarmReceiver +/** + * Manages the scheduling and cancellation of alarms using Android's [AlarmManager]. + * This class provides methods to schedule single or multiple alarms and to cancel existing ones. + */ class TriggerXAlarmScheduler { + /** + * Schedules an exact alarm to be triggered at a specific time. + * + * This function uses [AlarmManager.setExactAndAllowWhileIdle] to ensure the alarm fires + * even when the device is in Doze mode. It requires the `SCHEDULE_EXACT_ALARM` permission + * on Android S (API 31) and above. + * + * @param context The application context. + * @param triggerAtMillis The time in milliseconds at which the alarm should trigger. + * @param type A string classifying the type of alarm, passed to the receiver. + * @param alarmId A unique integer identifier for the alarm. Defaults to a value derived + * from the current system time. + * @return `true` if the alarm was successfully scheduled, `false` otherwise (e.g., if + * permission is denied or an exception occurs). + */ fun scheduleAlarm( context: Context, triggerAtMillis: Long, @@ -60,6 +79,16 @@ class TriggerXAlarmScheduler { } } + /** + * Schedules an exact alarm with a default empty type string. + * + * @param context The application context. + * @param triggerAtMillis The time in milliseconds at which the alarm should trigger. + * @param alarmId A unique integer identifier for the alarm. Defaults to a value derived + * from the current system time. + * @return `true` if the alarm was successfully scheduled, `false` otherwise. + * @see scheduleAlarm + */ fun scheduleAlarm( context: Context, triggerAtMillis: Long, @@ -67,6 +96,16 @@ class TriggerXAlarmScheduler { ) = scheduleAlarm(context, triggerAtMillis, "", alarmId) + /** + * Schedules multiple alarms based on a list of events. + * Each event is a pair of alarm ID and trigger time. + * + * @param context The application context. + * @param events A list of [Pair]s, where each pair contains an `Int` (alarmId) + * and a `Long` (triggerTime in milliseconds). + * @return A list of [Boolean] values, where each boolean indicates whether the + * corresponding alarm in the `events` list was successfully scheduled. + */ fun scheduleMultipleAlarms( context: Context, events: List> // Pair @@ -76,6 +115,15 @@ class TriggerXAlarmScheduler { } } + /** + * Cancels a previously scheduled alarm. + * + * If an alarm with the given `alarmId` exists, it will be cancelled. + * If no such alarm is found, this method does nothing. + * + * @param context The application context. + * @param alarmId The unique integer identifier of the alarm to cancel. + */ fun cancelAlarm(context: Context, alarmId: Int) { val intent = Intent(context, TriggerXAlarmReceiver::class.java).apply { action = TriggerXAlarmReceiver.ALARM_ACTION diff --git a/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt index faff469..1c68804 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerX.kt @@ -11,8 +11,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch object TriggerX { + + // Holds the global configuration for TriggerX. This is set during initialization. internal var config: TriggerXConfig? = null + /** + * Initializes the TriggerX library with a custom configuration. + * + * @param context The application context + * @param configure Lambda to configure the TriggerXConfig instance + */ fun init(context: Context, configure: TriggerXConfig.() -> Unit) { val conf = TriggerXConfig().apply(configure) config = conf @@ -27,6 +35,12 @@ object TriggerX { setupNotificationChannel(context.applicationContext) } + /** + * Sets up the notification channel required for foreground service notifications. + * This is required on Android 8.0+ (API 26+). + * + * @param context The application context + */ private fun setupNotificationChannel(context: Context) { val channelName = config?.notificationChannelName ?: "TriggerX Alarms" val channel = NotificationChannel( @@ -42,6 +56,7 @@ object TriggerX { const val DEFAULT_CHANNEL_ID = "triggerx_channel" - fun getNotificationTitle(): String = config?.notificationTitle ?: "Alarm" - fun getNotificationMessage(): String = config?.notificationMessage ?: "Alarm is ringing" + internal fun getNotificationTitle(): String = config?.notificationTitle ?: "Alarm" + internal 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 index 1ef5690..01fc47e 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerXConfig.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/dsl/TriggerXConfig.kt @@ -6,17 +6,81 @@ import com.meticha.triggerx.logger.TriggerXLogger import com.meticha.triggerx.provider.TriggerXDataProvider import kotlin.jvm.java +/** + * Configuration class for the TriggerX library. + * Allows customization of various aspects such as notifications, data providers, logging, and target activities. + * + * This class is typically used during the initialization of the TriggerX library. + * + * Example: + * ```kotlin + * TriggerX.init(this) { + * activityClass = MyAlarmActivity::class.java + * useDefaultNotification( + * title = "Alarm Running", + * message = "Tap to open", + * channelName = "Alarm Notifications" + * ) + * alarmDataProvider = MyDataProvider() + * logging(MyCustomLogger()) + * } + * ``` + */ class TriggerXConfig internal constructor() { + /** + * The title for the default notification displayed by the foreground service when an alarm fires. + * This is used if a custom notification setup is not provided. + * + * @see useDefaultNotification + */ internal var notificationTitle: String? = null + + /** + * The message body for the default notification displayed by the foreground service. + * This is used if a custom notification setup is not provided. + * + * @see useDefaultNotification + */ internal var notificationMessage: String? = null + + /** + * The name for the notification channel used by the default foreground service notification. + * Defaults to "TriggerX Alarms". + * + * @see useDefaultNotification + */ internal var notificationChannelName: String = "TriggerX Alarms" + /** + * An optional [TriggerXDataProvider] instance. + * If provided, its `provideData()` method will be called before the alarm activity is launched, + * allowing dynamic data to be passed to the activity. + */ var alarmDataProvider: TriggerXDataProvider? = null + /** + * An optional [TriggerXLogger] instance for custom logging within the TriggerX library. + * If not provided, a default logger (logging to Logcat) will be used. + * + * @see logging + */ internal var customLogger: TriggerXLogger? = null + + /** + * The [Activity] class that should be launched when an alarm fires. + * Defaults to [DefaultTriggerActivity]::class.java. + * This activity will receive any data provided by the [alarmDataProvider]. + */ var activityClass: Class = DefaultTriggerActivity::class.java + /** + * Configures the details for the default notification shown by the foreground service. + * + * @param title The title of the notification. + * @param message The message body of the notification. + * @param channelName The name for the notification channel. Defaults to "TriggerX Alarms". + */ fun useDefaultNotification( title: String, message: String, @@ -27,6 +91,11 @@ class TriggerXConfig internal constructor() { notificationChannelName = channelName } + /** + * Sets a custom logger for the TriggerX library. + * + * @param logger The [TriggerXLogger] instance to use for logging. + */ fun logging(logger: TriggerXLogger) { customLogger = logger } diff --git a/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt b/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt index 56cd37e..9e1d4df 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/DefaultTriggerXLogger.kt @@ -2,20 +2,46 @@ package com.meticha.triggerx.logger import android.util.Log -object DefaultTriggerXLogger : TriggerXLogger { +/** + * Default implementation of [TriggerXLogger] that logs messages using Android's [Log] utility. + * + * This logger uses a predefined tag from [LoggerConfig.TAG] for all log messages. + */ +internal object DefaultTriggerXLogger : TriggerXLogger { + /** + * Logs a debug message. + * + * @param message The message to be logged. + */ override fun d(message: String) { Log.d(LoggerConfig.TAG, message) } + /** + * Logs an error message along with an optional [Throwable]. + * + * @param message The error message to be logged. + * @param throwable An optional [Throwable] to log alongside the message (e.g., an exception). + */ override fun e(message: String, throwable: Throwable?) { Log.e(LoggerConfig.TAG, message, throwable) } + /** + * Logs an informational message. + * + * @param message The message to be logged. + */ override fun i(message: String) { Log.i(LoggerConfig.TAG, message) } + /** + * Logs a warning message. + * + * @param message The message to be logged. + */ 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 index eb4b0aa..55d9bc9 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLogger.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLogger.kt @@ -1,8 +1,36 @@ package com.meticha.triggerx.logger +/** + * Interface for logging within the TriggerX library. + * Allows for custom logger implementations to be plugged into TriggerX. + */ interface TriggerXLogger { + /** + * Logs a debug message. + * + * @param message The message to be logged. + */ fun d(message: String) + + /** + * Logs an error message, optionally with an associated [Throwable]. + * + * @param message The error message to be logged. + * @param throwable An optional [Throwable] (e.g., an exception) to log with the message. + */ fun e(message: String, throwable: Throwable? = null) + + /** + * Logs an informational message. + * + * @param message The message to be logged. + */ fun i(message: String) + + /** + * Logs a warning message. + * + * @param message The message to be logged. + */ 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 index 6fef169..1511688 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt @@ -1,7 +1,20 @@ package com.meticha.triggerx.logger -object LoggerConfig { +/** + * Configuration object for logging within the TriggerX library. + * It holds settings such as the default log tag and the active logger instance. + */ +internal object LoggerConfig { + /** + * The default tag used for Logcat messages by the [DefaultTriggerXLogger]. + */ const val TAG = "TRIGGERX" + /** + * The [TriggerXLogger] instance used by the library for logging. + * By default, this is initialized to [DefaultTriggerXLogger]. + * It can be overridden to use a custom logger implementation. + * @see com.meticha.triggerx.dsl.TriggerXConfig.logging + */ var logger: TriggerXLogger = DefaultTriggerXLogger -} \ No newline at end of file +} diff --git a/triggerx/src/main/java/com/meticha/triggerx/permission/PermissionLifeCycleCheckEffect.kt b/triggerx/src/main/java/com/meticha/triggerx/permission/PermissionLifeCycleCheckEffect.kt new file mode 100644 index 0000000..c3ed558 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/PermissionLifeCycleCheckEffect.kt @@ -0,0 +1,45 @@ +package com.meticha.triggerx.permission + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.meticha.triggerx.logger.LoggerConfig + + +/** + * A Composable side-effect that observes lifecycle events to re-evaluate and request permissions. + * + * This is primarily used to detect when the user returns to the app after potentially changing + * permissions in the system settings. When the specified [lifecycleEvent] (defaulting to `ON_RESUME`) + * occurs, if `permissionState.resumedFromSettings` is true, it triggers a new permission request + * sequence. + * + * @param permissionState The current [PermissionState] to be monitored and updated. + * @param lifecycleEvent The [Lifecycle.Event] to observe for triggering the permission check. + * Defaults to [Lifecycle.Event.ON_RESUME]. + */ +@Composable +internal fun PermissionLifeCycleCheckEffect( + permissionState: PermissionState, + lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME, +) { + + val observer = LifecycleEventObserver { _, event -> + LoggerConfig.logger.d("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) + } + } +} \ 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 e5ce4bc..210787c 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -19,6 +19,24 @@ import com.meticha.triggerx.permission.AlarmPermissionManager.isGranted import java.lang.ref.WeakReference +/** + * Remembers and manages the state of essential permissions required by the TriggerX library. + * + * This composable function initializes a [PermissionState] that tracks and helps request + * permissions for exact alarms, overlays (display over other apps), battery optimization exemption, + * and notifications (on API 33+). It also includes special considerations for Xiaomi devices + * regarding lock screen display permissions. + * + * The [PermissionState] returned by this function can be used to check if all required + * permissions are granted and to trigger permission requests. It handles the complexities + * of launching system dialogs for these permissions and reacting to their results. + * + * It also sets up a [PermissionLifeCycleCheckEffect] to re-check permissions when the + * composable's lifecycle resumes, which is useful for when the user returns from system settings. + * + * @return A [PermissionState] instance that holds the current status of all relevant permissions + * and provides methods to request them. + */ @SuppressLint("ComposableNaming") @Composable fun rememberAppPermissionState(): PermissionState { @@ -96,32 +114,20 @@ private fun handlePermissionDenial(permissionState: PermissionState) { } -@Composable -fun PermissionLifeCycleCheckEffect( - permissionState: PermissionState, - lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME, -) { - - val observer = LifecycleEventObserver { _, event -> - LoggerConfig.logger.d("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) - } - } -} +/** + * A composable function that displays a standard [AlertDialog]. + * + * This popup is typically used to show rationale to the user before requesting a sensitive permission + * or to inform them about the necessity of a permission if it has been denied. + * + * @param message The main message to be displayed in the dialog. + * @param onConfirm A lambda function to be executed when the confirm button (e.g., "Grant") is clicked. + * @param onDismiss A lambda function to be executed when the dismiss button (e.g., "Cancel") is clicked + * or when the dialog is dismissed by tapping outside or pressing the back button. + */ @Composable -fun ShowPopup( +internal fun ShowPopup( message: String, onConfirm: () -> Unit, onDismiss: () -> Unit, @@ -149,4 +155,4 @@ fun ShowPopup( } } ) -} \ 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 index 6c84f21..799c0bb 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt @@ -21,8 +21,17 @@ import androidx.core.net.toUri import java.lang.ref.WeakReference import java.lang.reflect.Method - -object AlarmPermissionManager { +/** + * Manages permission checks and Intent creation for various Android permissions + * required by the TriggerX library. + */ +internal object AlarmPermissionManager { + /** + * Checks if the app has permission to schedule exact alarms. + * + * @param context The application context. + * @return `true` if exact alarm permission is granted or not required (pre-Android S), `false` otherwise. + */ fun hasExactAlarmPermission(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() @@ -31,10 +40,22 @@ object AlarmPermissionManager { } } + /** + * Checks if the app has permission to draw overlays on top of other apps. + * + * @param context The application context. + * @return `true` if overlay permission is granted, `false` otherwise. + */ fun hasOverlayPermission(context: Context): Boolean { return Settings.canDrawOverlays(context) } + /** + * Checks if the app is whitelisted from battery optimizations. + * + * @param context The application context. + * @return `true` if the app is ignoring battery optimizations, `false` otherwise. + */ fun hasBatteryOptimizationPermission(context: Context): Boolean { val powerManager = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager @@ -42,6 +63,13 @@ object AlarmPermissionManager { return powerManager.isIgnoringBatteryOptimizations(packageName) } + /** + * Checks if the "Show on Lock Screen" permission is enabled, typically for Xiaomi (MIUI) devices. + * This uses reflection to access a hidden API and may not work on all devices or future Android versions. + * + * @param context The application context. + * @return `true` if the permission appears to be enabled, `false` otherwise or if an error occurs. + */ @SuppressLint("DiscouragedPrivateApi") // TODO("Need to check in future") fun isShowOnLockScreenPermissionEnable(context: Context): Boolean { @@ -61,6 +89,12 @@ object AlarmPermissionManager { } } + /** + * Checks if the app has permission to post notifications. + * + * @param context The application context. + * @return `true` if notification permission is granted or not required (pre-Android Tiramisu), `false` otherwise. + */ fun isNotificationPermissionEnabled(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( @@ -72,6 +106,13 @@ object AlarmPermissionManager { } } + /** + * Checks if a specific [PermissionType] is granted. + * + * @param context The application context. + * @param permission The [PermissionType] to check. + * @return `true` if the specified permission is granted, `false` otherwise. + */ fun isGranted(context: Context, permission: PermissionType): Boolean { return when (permission) { PermissionType.ALARM -> hasExactAlarmPermission(context) @@ -82,6 +123,16 @@ object AlarmPermissionManager { } } + /** + * Creates an [Intent] to request a specific [PermissionType] from the user. + * The user should be directed to the system settings screen corresponding to this intent. + * + * @param context The application context. + * @param permissionType The [PermissionType] for which to create the request intent. + * @return An [Intent] that can be used to launch the relevant system settings screen. + * Returns an empty Intent for [PermissionType.NOTIFICATION] on pre-Tiramisu devices + * as no explicit system settings intent is typically used. + */ fun createPermissionIntent(context: Context, permissionType: PermissionType): Intent { when (permissionType) { PermissionType.ALARM -> { @@ -103,6 +154,7 @@ object AlarmPermissionManager { } PermissionType.LOCK_SCREEN -> { + // This intent is specific to MIUI and might not work on other devices. return Intent("miui.intent.action.APP_PERM_EDITOR").apply { setClassName( "com.miui.securitycenter", @@ -118,42 +170,81 @@ object AlarmPermissionManager { putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) } } else { - Intent() + Intent() // No standard intent pre-Tiramisu, permission granted by default. } } - } } } -enum class PermissionType { ALARM, OVERLAY, BATTERY_OPTIMIZATION, LOCK_SCREEN, NOTIFICATION } +/** + * Represents the different types of permissions that the TriggerX library + * may need to check or request. + */ +enum class PermissionType { + /** Permission to schedule exact alarms (Android S+). */ + ALARM, + + /** Permission to draw overlays on top of other applications. */ + OVERLAY, + + /** Permission to be exempt from battery optimizations. */ + BATTERY_OPTIMIZATION, + + /** Special permission for showing on the lock screen, primarily for Xiaomi (MIUI) devices. */ + LOCK_SCREEN, + + /** Permission to post notifications (Android Tiramisu+). */ + NOTIFICATION +} /** - * Manages the state of permission requests and their UI flows + * Manages the state of permission requests and their UI flows, particularly for a list + * of required permissions. This class is typically used in conjunction with Jetpack Compose. + * + * @param permissionList The initial list of [PermissionType]s that this state manager will handle. */ class PermissionState( permissionList: List, ) { - // WeakReference to context to avoid memory leaks + /** + * A [WeakReference] to the [Context] to avoid memory leaks. + * This should be set by the composable that initializes [PermissionState]. + */ lateinit var contextRef: WeakReference - // All permissions that need to be handled + // All permissions that need to be handled by this instance private val allPermissions = mutableStateListOf() - // Permissions waiting to be processed + // Permissions currently waiting to be processed in the request queue private var pendingPermissions = mutableStateListOf() - // Currently processing permission + /** + * The [PermissionType] currently being processed or for which a rationale might be shown. + * Null if no permission is currently active in the flow. + */ internal var currentPermission by mutableStateOf(null) - // UI state + /** + * Controls whether a rationale popup should be shown to the user. + * Set to `true` to indicate a rationale is needed before re-requesting a denied permission. + */ internal var showRationalePopUp by mutableStateOf(false) + + /** + * Flag to indicate if the app has recently resumed from a system settings screen + * where the user might have changed permissions. + */ internal var resumedFromSettings by mutableStateOf(false) - // Permission states + // Internal state tracking if all required permissions are granted. private var isRequiredPermissionGranted by mutableStateOf(false) - // Permission request launcher + /** + * The [ManagedActivityResultLauncher] used to launch permission request intents + * (e.g., system settings screens) and handle their results. + * This should be initialized and set by the composable that uses [PermissionState]. + */ internal var launcher: ManagedActivityResultLauncher? = null init { @@ -162,24 +253,40 @@ class PermissionState( } /** - * Checks if all required permissions are actually granted + * Checks if all permissions in the `allPermissions` list for this state + * are currently granted. + * + * @return `true` if all required permissions are granted, `false` otherwise. + * This also updates an internal state flag. */ fun allRequiredGranted(): Boolean { + val context = contextRef.get() ?: return false // Cannot check without context isRequiredPermissionGranted = allPermissions .all { isGranted(it) } return isRequiredPermissionGranted } /** - * Checks if a specific permission is granted + * Checks if a specific [PermissionType] is granted. + * Requires [contextRef] to be initialized and valid. + * + * @param permission The [PermissionType] to check. + * @return `true` if the permission is granted, `false` otherwise or if context is unavailable. + * @throws IllegalStateException if [contextRef] has not been initialized. + * @throws NullPointerException if [contextRef.get()] returns null after initialization. */ fun isGranted(permission: PermissionType): Boolean { - val context = requireNotNull(contextRef.get()) + val context = + requireNotNull(contextRef.get()) { "Context not available. Ensure contextRef is set." } return AlarmPermissionManager.isGranted(context, permission) } /** - * Starts or continues the permission request flow + * Starts or continues the permission request flow. + * It processes the permissions from the `pendingPermissions` queue one by one. + * If a permission is already granted, it moves to the next. + * Otherwise, it launches the appropriate system intent using the [launcher]. + * Requires [contextRef] and [launcher] to be initialized. */ fun requestPermission() { if (pendingPermissions.isNotEmpty()) { @@ -188,27 +295,30 @@ class PermissionState( if (isGranted(permission)) { next() // Permission already granted, move to next } else { + val context = + requireNotNull(contextRef.get()) { "Context not available for launching intent." } launcher?.launch( AlarmPermissionManager.createPermissionIntent( - requireNotNull(contextRef.get()), + context, permission ) ) } } + } else { + currentPermission = null // No more pending permissions } } /** - * Moves to the next permission in the queue + * Moves to the next permission in the `pendingPermissions` queue + * and then attempts to request it. + * This is typically called after a permission request has been handled (granted or denied). */ internal fun next() { if (pendingPermissions.isNotEmpty()) { pendingPermissions.removeAt(0) } - - requestPermission() + requestPermission() // Attempt to process the next permission, or clear currentPermission if queue is empty } - } - diff --git a/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt b/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt index 53e8568..1d7386e 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/preference/TriggerXPreference.kt @@ -11,13 +11,41 @@ import com.meticha.triggerx.dsl.TriggerXConfig import com.meticha.triggerx.logger.LoggerConfig import kotlinx.coroutines.flow.firstOrNull -object TriggerXPreferences { +/** + * Manages the persistence of TriggerX configuration settings using Jetpack DataStore. + * This object is responsible for saving and loading essential configuration details + * that need to survive app restarts, such as the target activity class and + * notification content. + */ +internal object TriggerXPreferences { + /** + * Provides a [DataStore] instance for storing preferences, named "triggerx_prefs". + * This is an extension property on [Context]. + */ private val Context.dataStore: DataStore by preferencesDataStore(name = "triggerx_prefs") + /** + * Preference key for storing the fully qualified class name of the activity to be launched by TriggerX. + */ private val KEY_ACTIVITY_CLASS = stringPreferencesKey("activity_class") + + /** + * Preference key for storing the title of the notification displayed by the foreground service. + */ private val KEY_NOTIFICATION_TITLE = stringPreferencesKey("notification_title") + + /** + * Preference key for storing the message content of the notification displayed by the foreground service. + */ private val KEY_NOTIFICATION_MESSAGE = stringPreferencesKey("notification_message") + /** + * Saves the provided [TriggerXConfig] to DataStore. + * This includes the activity class name, and optionally the notification title and message. + * + * @param context The [Context] used to access DataStore. + * @param config The [TriggerXConfig] instance containing the settings to save. + */ suspend fun save(context: Context, config: TriggerXConfig) { context.dataStore.edit { prefs -> prefs[KEY_ACTIVITY_CLASS] = config.activityClass.name @@ -30,11 +58,22 @@ object TriggerXPreferences { } } + /** + * Loads the [TriggerXConfig] from DataStore. + * If essential data (like the activity class name) is missing, or if the class + * cannot be loaded, it returns `null`. Default values are used for notification + * title and message if they are not found in preferences. + * + * @param context The [Context] used to access DataStore. + * @return A [TriggerXConfig] instance populated with the loaded settings, + * or `null` if loading fails or essential data is missing. + */ + @Suppress("UNCHECKED_CAST") 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" + val title = prefs[KEY_NOTIFICATION_TITLE] ?: "Alarm" // Default title + val message = prefs[KEY_NOTIFICATION_MESSAGE] ?: "Alarm is ringing" // Default message return try { val clazz = Class.forName(className) as Class 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 781d336..3baa801 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt @@ -6,12 +6,38 @@ import android.content.Intent import com.meticha.triggerx.logger.LoggerConfig import com.meticha.triggerx.services.TriggerXForegroundService -class TriggerXAlarmReceiver : BroadcastReceiver() { - +/** + * A [BroadcastReceiver] that listens for scheduled alarms and initiates the foreground service + * to handle the alarm event. + * + * This receiver is triggered by the [android.app.AlarmManager] when an exact alarm, + * scheduled by the application, fires. Its primary responsibility is to start the + * [TriggerXForegroundService], passing along any relevant alarm details. + */ +internal class TriggerXAlarmReceiver : BroadcastReceiver() { + + /** + * Companion object for [TriggerXAlarmReceiver]. + */ companion object { + /** + * The specific action string that this receiver listens for. + * Alarms should be scheduled with an Intent using this action. + */ const val ALARM_ACTION = "com.meticha.triggerx.ALARM_ACTION" } + /** + * Called when the BroadcastReceiver is receiving an Intent broadcast. + * + * This method checks if the received Intent matches the [ALARM_ACTION]. + * If it matches, it extracts alarm details (ID and type) from the Intent's extras + * and starts the [TriggerXForegroundService], passing these details to the service. + * + * @param context The Context in which the receiver is running. + * @param intent The Intent being received. This should contain the [ALARM_ACTION] + * and extras for "ALARM_ID" and "ALARM_TYPE". + */ override fun onReceive(context: Context, intent: Intent?) { LoggerConfig.logger.d("Alarm received!") @@ -20,7 +46,7 @@ class TriggerXAlarmReceiver : BroadcastReceiver() { val alarmType = intent.getStringExtra("ALARM_TYPE") ?: "" val serviceIntent = Intent(context, TriggerXForegroundService::class.java).also { - it.action = ALARM_ACTION + it.action = ALARM_ACTION // Set action for the service intent as well it.putExtra("ALARM_ID", alarmId) it.putExtra("ALARM_TYPE", alarmType) } @@ -29,6 +55,4 @@ class TriggerXAlarmReceiver : BroadcastReceiver() { LoggerConfig.logger.w("Received intent with unknown action: ${intent?.action}") } } - - -} \ No newline at end of file +} 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 18af147..990afe7 100644 --- a/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt +++ b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt @@ -18,20 +18,72 @@ import com.meticha.triggerx.receivers.TriggerXAlarmReceiver.Companion.ALARM_ACTI import kotlinx.coroutines.runBlocking import kotlin.jvm.java -class TriggerXForegroundService : Service() { +/** + * A [Service] that runs in the foreground to handle alarm events. + * + * This service is started by [com.meticha.triggerx.receivers.TriggerXAlarmReceiver] when an alarm fires. + * It's responsible for: + * 1. Displaying a foreground notification to comply with Android foreground service requirements. + * 2. Acquiring a wake lock to ensure the device stays awake during processing. + * 3. Fetching alarm-specific data using [com.meticha.triggerx.provider.TriggerXDataProvider]. + * 4. Launching the configured target [Activity] with the alarm data. + * 5. Releasing the wake lock and stopping itself once the work is done. + */ +internal class TriggerXForegroundService : Service() { + /** + * Companion object for [TriggerXForegroundService]. + * Contains constants used by the service. + */ companion object { + /** + * Unique ID for the foreground service notification. + */ private const val NOTIFICATION_ID = 1001 + + /** + * Default channel ID for the foreground service notification. + */ private const val DEFAULT_CHANNEL_ID = "triggerx_channel" } + /** + * Returns null as this service is not designed for binding. + * @param intent The Intent that was used to bind to this service. + * @return Always returns null. + */ override fun onBind(intent: Intent?): IBinder? = null + /** + * Called by the system when the service is first created. + * Responsible for creating the notification channel for the foreground service. + */ override fun onCreate() { super.onCreate() createNotificationChannel() } - + /** + * Called by the system every time a client starts the service using [Context.startService]. + * This method handles the core logic of the alarm execution. + * + * It performs the following steps: + * - Builds and displays a foreground notification. + * - Acquires a partial wake lock. + * - Processes the incoming alarm intent: + * - Extracts alarm ID and type. + * - Fetches data using [TriggerX.config.alarmDataProvider]. + * - Resolves the target activity class. + * - Starts the target activity with alarm data. + * - Stops the service itself. + * - Releases the wake lock in a finally block. + * + * @param intent The Intent supplied to [Context.startService]. This should contain + * the alarm action, ID, and type. + * @param flags Additional data about this start request. + * @param startId A unique integer representing this specific request to start. + * @return [START_NOT_STICKY] to indicate that if the service is killed, it should not be + * automatically restarted. + */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val notification = buildNotification() @@ -95,11 +147,19 @@ class TriggerXForegroundService : Service() { return START_NOT_STICKY } + /** + * Called by the system to notify a Service that it is no longer used and is being removed. + * Logs the destruction of the service. + */ override fun onDestroy() { super.onDestroy() LoggerConfig.logger.d("Service destroyed") } + /** + * Creates the notification channel for the foreground service. + * Uses the channel name from [TriggerX.config] or a default name. + */ private fun createNotificationChannel() { val channelName = TriggerX.config?.notificationChannelName ?: "TriggerX Alarms" val channel = NotificationChannel( @@ -113,6 +173,11 @@ class TriggerXForegroundService : Service() { nm.createNotificationChannel(channel) } + /** + * Builds the [Notification] instance for the foreground service. + * Uses the title and message from [TriggerX.getNotificationTitle] and [TriggerX.getNotificationMessage]. + * @return The configured [Notification] object. + */ private fun buildNotification(): Notification { return NotificationCompat.Builder(this, DEFAULT_CHANNEL_ID) .setContentTitle(TriggerX.getNotificationTitle()) @@ -121,6 +186,16 @@ class TriggerXForegroundService : Service() { .build() } + /** + * Resolves the [Activity] class that should be launched when the alarm fires. + * The class is determined in the following order of preference: + * 1. [TriggerX.config.activityClass] + * 2. Class loaded from [TriggerXPreferences] + * 3. [DefaultTriggerActivity] + * + * @param context The application context, used for loading preferences. + * @return The [Class] of the Activity to be launched. + */ suspend fun resolveActivityClass(context: Context): Class { return TriggerX.config?.activityClass ?: TriggerXPreferences.load(context)?.activityClass