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/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/.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/.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..16011ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,16 @@ + + + + + + + + - - + + + \ 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 3a86c68..21498ee 100644 --- a/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt +++ b/app/src/main/java/com/meticha/triggerxexample/MainActivity.kt @@ -4,44 +4,23 @@ 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.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 + +@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..07f3ac4 --- /dev/null +++ b/app/src/main/java/com/meticha/triggerxexample/TriggerXApplication.kt @@ -0,0 +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() { + 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/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..966c4f7 --- /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() : 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) + }.timeInMillis + + Log.i(TAG, "Attempting to schedule alarm for 1 minute from now ($triggerTime)") + return appAlarmManager.scheduleAlarm(context, triggerTime) + } + + fun cancelCurrentAlarm(context: Context, id: Int) { + appAlarmManager.cancelAlarm(context, id) + } +} \ 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..8a67cc7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.12.0-alpha03" -kotlin = "2.0.21" +datastorePreferences = "1.1.7" +kotlin = "2.1.20" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -8,9 +9,16 @@ 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" } +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" } @@ -24,9 +32,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..f10abcb --- /dev/null +++ b/triggerx/build.gradle.kts @@ -0,0 +1,67 @@ +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) + + // datastore + implementation(libs.androidx.datastore.preferences) + + + // 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..68a159d --- /dev/null +++ b/triggerx/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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/TriggerXActivity.kt b/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt new file mode 100644 index 0000000..a78a2fe --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXActivity.kt @@ -0,0 +1,38 @@ +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.runtime.Composable + + +abstract class TriggerXActivity : ComponentActivity() { + + /** Override this to paint your own UI. */ + @Composable + abstract fun AlarmContent() + + 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) + } else { + @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 + ) + } + + // ── 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 new file mode 100644 index 0000000..3dda0e4 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/TriggerXAlarmScheduler.kt @@ -0,0 +1,90 @@ +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 androidx.core.content.getSystemService +import com.meticha.triggerx.logger.LoggerConfig +import com.meticha.triggerx.receivers.TriggerXAlarmReceiver + +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) { + if (!alarmManager.canScheduleExactAlarms()) { + 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 + } + } + + val intent = Intent(context, TriggerXAlarmReceiver::class.java).apply { + action = TriggerXAlarmReceiver.ALARM_ACTION + } + val pendingIntent = PendingIntent.getBroadcast( + /* context = */ context, + /* requestCode = */ alarmId, + /* intent = */ intent, + /* flags = */ 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 + ) + LoggerConfig.logger.i("Alarm [$alarmId] scheduled for $triggerAtMillis") + return true + } catch (se: SecurityException) { + LoggerConfig.logger.e( + "SecurityException while scheduling alarm. Check permissions.", + se + ) + return false + } catch (e: Exception) { + LoggerConfig.logger.e("Exception while scheduling alarm", e) + return false + } + } + + 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, + 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 [$alarmId] cancelled.") + } else { + 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/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..6fef169 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/logger/TriggerXLoggerConfig.kt @@ -0,0 +1,7 @@ +package com.meticha.triggerx.logger + +object LoggerConfig { + const val TAG = "TRIGGERX" + + var logger: TriggerXLogger = DefaultTriggerXLogger +} \ 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..e5ce4bc --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionComposable.kt @@ -0,0 +1,152 @@ +package com.meticha.triggerx.permission + +import android.annotation.SuppressLint +import android.os.Build +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.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 + + +@SuppressLint("ComposableNaming") +@Composable +fun rememberAppPermissionState(): PermissionState { + + val permissions = buildList { + addAll( + listOf( + PermissionType.ALARM, + PermissionType.OVERLAY, + PermissionType.BATTERY_OPTIMIZATION, + PermissionType.NOTIFICATION + ) + ) + if (Build.MANUFACTURER.equals("Xiaomi", true)) { + add(PermissionType.LOCK_SCREEN) + } + } + + 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 = "Permissions are required to proceed further", + 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 +} + + +@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) + } + } +} + +@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 = "Grant") + } + } + ) +} \ 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..6c84f21 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/permission/TriggerXPermissionManager.kt @@ -0,0 +1,214 @@ +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.content.pm.PackageManager +import android.os.Binder +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.content.ContextCompat +import androidx.core.net.toUri +import java.lang.ref.WeakReference +import java.lang.reflect.Method + + +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) + } + + @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 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) + } + } + + 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() + } + } + + 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) + } + } + + 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, NOTIFICATION } + +/** + * 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/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/receivers/TriggerXAlarmReceiver.kt b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt new file mode 100644 index 0000000..d1e9b9e --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/receivers/TriggerXAlarmReceiver.kt @@ -0,0 +1,29 @@ +package com.meticha.triggerx.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.meticha.triggerx.logger.LoggerConfig +import com.meticha.triggerx.services.TriggerXForegroundService + +class TriggerXAlarmReceiver : BroadcastReceiver() { + + companion object { + const val ALARM_ACTION = "com.meticha.triggerx.ALARM_ACTION" + } + + override fun onReceive(context: Context, intent: Intent?) { + LoggerConfig.logger.d("Alarm received!") + + if (intent?.action == ALARM_ACTION) { + val serviceIntent = Intent(context, TriggerXForegroundService::class.java).also { + it.action = ALARM_ACTION + } + context.startForegroundService(serviceIntent) + } else { + 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 new file mode 100644 index 0000000..88f3201 --- /dev/null +++ b/triggerx/src/main/java/com/meticha/triggerx/services/TriggerXForegroundService.kt @@ -0,0 +1,120 @@ +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 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 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( + 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*/) + LoggerConfig.logger.d("WakeLock acquired") + + try { + if (intent?.action == ALARM_ACTION) { + 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) + LoggerConfig.logger.d("TriggerActivity started") + stopSelf() + LoggerConfig.logger.d("stopSelf() called") + } else { + 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() + LoggerConfig.logger.d("stopSelf() called due to unknown action") + } + } catch (e: Exception) { + 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() + LoggerConfig.logger.d("stopSelf() called after exception") + } finally { + if (wakeLock.isHeld) { + wakeLock.release() + LoggerConfig.logger.d("WakeLock released") + } else { + LoggerConfig.logger.d("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() + 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 + } +} 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