From 5aec11b7775363c94f24f33b44559160f6f846ed Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 11 Jan 2026 06:53:07 +0530 Subject: [PATCH] Initial location alarm impl --- app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 33 +- .../essentials/FeatureSettingsActivity.kt | 29 ++ .../com/sameerasw/essentials/MainActivity.kt | 18 +- .../repository/LocationReachedRepository.kt | 57 ++++ .../essentials/domain/model/LocationAlarm.kt | 8 + .../domain/registry/FeatureRegistry.kt | 13 + .../domain/registry/PermissionRegistry.kt | 5 + .../services/LocationReachedService.kt | 275 ++++++++++++++++ .../ui/activities/LocationAlarmActivity.kt | 227 +++++++++++++ .../configs/LocationReachedSettingsUI.kt | 298 ++++++++++++++++++ .../essentials/utils/PermissionUtils.kt | 27 ++ .../viewmodels/LocationReachedViewModel.kt | 245 ++++++++++++++ .../essentials/viewmodels/MainViewModel.kt | 41 +++ .../drawable/rounded_add_location_alt_24.xml | 5 + .../main/res/drawable/rounded_delete_24.xml | 5 + .../res/drawable/rounded_location_on_24.xml | 5 + app/src/main/res/drawable/rounded_map_24.xml | 5 + .../res/drawable/rounded_my_location_24.xml | 5 + .../main/res/drawable/rounded_pause_24.xml | 3 + app/src/main/res/values/strings.xml | 20 ++ app/src/main/res/values/themes.xml | 6 + gradle/libs.versions.toml | 6 +- 23 files changed, 1336 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt create mode 100644 app/src/main/res/drawable/rounded_add_location_alt_24.xml create mode 100644 app/src/main/res/drawable/rounded_delete_24.xml create mode 100644 app/src/main/res/drawable/rounded_location_on_24.xml create mode 100644 app/src/main/res/drawable/rounded_map_24.xml create mode 100644 app/src/main/res/drawable/rounded_my_location_24.xml create mode 100644 app/src/main/res/drawable/rounded_pause_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f619511d..74824ecb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,9 +69,9 @@ dependencies { implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.ui) implementation(libs.androidx.compose.foundation) + implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) @@ -94,4 +94,7 @@ dependencies { // Volume Long Press implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1") implementation("dev.rikka.shizuku:api:13.1.5") + + // Google Maps & Location + implementation(libs.play.services.location) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d7cb0ce8..72bf52e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,14 +5,17 @@ - + + + + @@ -22,6 +25,9 @@ + + + + + + + + + + + + + + + + @@ -62,6 +81,18 @@ android:theme="@style/Theme.Essentials"> + + + + !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value "App lock" -> !isAccessibilityEnabled "Freeze" -> !viewModel.isShizukuAvailable.value || !viewModel.isShizukuPermissionGranted.value + "Location reached" -> !viewModel.isLocationPermissionGranted.value || !viewModel.isBackgroundLocationPermissionGranted.value else -> false } showPermissionSheet = hasMissingPermissions @@ -385,6 +387,26 @@ class FeatureSettingsActivity : FragmentActivity() { isGranted = viewModel.isShizukuPermissionGranted.value ) ) + "Location reached" -> listOf( + PermissionItem( + iconRes = R.drawable.rounded_navigation_24, + title = R.string.perm_location_title, + description = R.string.perm_location_desc, + dependentFeatures = PermissionRegistry.getFeatures("LOCATION"), + actionLabel = R.string.perm_action_grant, + action = { viewModel.requestLocationPermission(this) }, + isGranted = viewModel.isLocationPermissionGranted.value + ), + PermissionItem( + iconRes = R.drawable.rounded_navigation_24, + title = R.string.perm_bg_location_title, + description = R.string.perm_bg_location_desc, + dependentFeatures = PermissionRegistry.getFeatures("BACKGROUND_LOCATION"), + actionLabel = R.string.perm_action_grant, + action = { viewModel.requestBackgroundLocationPermission(this) }, + isGranted = viewModel.isBackgroundLocationPermissionGranted.value + ) + ) else -> emptyList() } @@ -520,6 +542,13 @@ class FeatureSettingsActivity : FragmentActivity() { highlightSetting = highlightSetting ) } + "Location reached" -> { + LocationReachedSettingsUI( + mainViewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting + ) + } // else -> default UI (optional cleanup) } } diff --git a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt index ace7c5be..6a993e2f 100644 --- a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt @@ -43,6 +43,7 @@ import com.sameerasw.essentials.ui.composables.ComingSoonDIYScreen import com.sameerasw.essentials.ui.theme.EssentialsTheme import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel +import com.sameerasw.essentials.viewmodels.LocationReachedViewModel import com.sameerasw.essentials.ui.components.sheets.UpdateBottomSheet import com.sameerasw.essentials.ui.components.sheets.InstructionsBottomSheet import com.sameerasw.essentials.ui.composables.configs.FreezeSettingsUI @@ -56,6 +57,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) class MainActivity : FragmentActivity() { val viewModel: MainViewModel by viewModels() + val locationViewModel: LocationReachedViewModel by viewModels() private var isAppReady = false override fun onCreate(savedInstanceState: Bundle?) { @@ -78,7 +80,7 @@ class MainActivity : FragmentActivity() { splashScreen.setOnExitAnimationListener { splashScreenViewProvider -> try { val splashScreenView = splashScreenViewProvider.view - val splashIcon = splashScreenViewProvider.iconView + val splashIcon = try { splashScreenViewProvider.iconView } catch (e: Exception) { null } // Animate the splash screen view fade out val fadeOut = ObjectAnimator.ofFloat(splashScreenView, "alpha", 1f, 0f).apply { @@ -139,6 +141,7 @@ class MainActivity : FragmentActivity() { } Log.d("MainActivity", "onCreate with action: ${intent?.action}") + handleLocationIntent(intent) // Initialize HapticUtil with saved preferences HapticUtil.initialize(this) @@ -301,6 +304,19 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) Log.d("MainActivity", "onNewIntent with action: ${intent.action}") + handleLocationIntent(intent) + } + + private fun handleLocationIntent(intent: Intent?) { + intent?.let { + if (locationViewModel.handleIntent(it)) { + val settingsIntent = Intent(this, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Location reached") + } + startActivity(settingsIntent) + } + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt new file mode 100644 index 00000000..170837ff --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt @@ -0,0 +1,57 @@ +package com.sameerasw.essentials.data.repository + +import android.content.Context +import android.content.SharedPreferences +import com.sameerasw.essentials.domain.model.LocationAlarm + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class LocationReachedRepository(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + + companion object { + private val _isProcessing = MutableStateFlow(false) + val isProcessing = _isProcessing.asStateFlow() + + private val _alarmFlow = MutableStateFlow(null) + val alarmFlow = _alarmFlow.asStateFlow() + } + + init { + if (_alarmFlow.value == null) { + _alarmFlow.value = getAlarm() + } + } + + fun setIsProcessing(processing: Boolean) { + _isProcessing.value = processing + } + + fun saveAlarm(alarm: LocationAlarm) { + prefs.edit().apply { + putLong("location_reached_lat", java.lang.Double.doubleToRawLongBits(alarm.latitude)) + putLong("location_reached_lng", java.lang.Double.doubleToRawLongBits(alarm.longitude)) + putInt("location_reached_radius", alarm.radius) + putBoolean("location_reached_enabled", alarm.isEnabled) + apply() + } + _alarmFlow.value = alarm + } + + fun getAlarm(): LocationAlarm { + val lat = java.lang.Double.longBitsToDouble(prefs.getLong("location_reached_lat", java.lang.Double.doubleToRawLongBits(0.0))) + val lng = java.lang.Double.longBitsToDouble(prefs.getLong("location_reached_lng", java.lang.Double.doubleToRawLongBits(0.0))) + val radius = prefs.getInt("location_reached_radius", 1000) + val enabled = prefs.getBoolean("location_reached_enabled", false) + return LocationAlarm(lat, lng, radius, enabled) + } + + fun saveStartDistance(distance: Float) { + prefs.edit().putFloat("location_reached_start_dist", distance).apply() + } + + fun getStartDistance(): Float { + return prefs.getFloat("location_reached_start_dist", 0f) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt new file mode 100644 index 00000000..90e8bbf6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt @@ -0,0 +1,8 @@ +package com.sameerasw.essentials.domain.model + +data class LocationAlarm( + val latitude: Double = 0.0, + val longitude: Double = 0.0, + val radius: Int = 1000, // in meters + val isEnabled: Boolean = false +) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 87454ca7..25600559 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -414,6 +414,19 @@ object FeatureRegistry { override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = viewModel.setAppLockEnabled(enabled, context) }, + object : Feature( + id = "Location reached", + title = R.string.feat_location_reached_title, + iconRes = R.drawable.rounded_navigation_24, + category = R.string.cat_tools, + description = R.string.feat_location_reached_desc, + permissionKeys = listOf("LOCATION", "BACKGROUND_LOCATION", "USE_FULL_SCREEN_INTENT"), + showToggle = false + ) { + override fun isEnabled(viewModel: MainViewModel) = true + override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {} + }, + object : Feature( id = "Freeze", title = R.string.feat_freeze_title, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index d7a2ed08..8dbf3ca4 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -47,4 +47,9 @@ fun initPermissionRegistry() { // Device Admin permission PermissionRegistry.register("DEVICE_ADMIN", R.string.feat_screen_locked_security_title) + + // Location permission + PermissionRegistry.register("LOCATION", R.string.feat_location_reached_title) + PermissionRegistry.register("BACKGROUND_LOCATION", R.string.feat_location_reached_title) + PermissionRegistry.register("USE_FULL_SCREEN_INTENT", R.string.feat_location_reached_title) } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt new file mode 100644 index 00000000..26fa2f48 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt @@ -0,0 +1,275 @@ +package com.sameerasw.essentials.services + +import android.app.* +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.Icon +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.LocationReachedRepository +import com.sameerasw.essentials.MainActivity +import kotlinx.coroutines.* +import kotlin.math.* + +class LocationReachedService : Service() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var trackingJob: Job? = null + private var isAlarmTriggered = false + + private val repository by lazy { LocationReachedRepository(this) } + private val fusedLocationClient by lazy { LocationServices.getFusedLocationProviderClient(this) } + private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } + + companion object { + private const val NOTIFICATION_ID = 2001 + private const val ALARM_NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "location_reached_live" + private const val ACTION_STOP = "com.sameerasw.essentials.STOP_LOCATION_REACHED" + + fun start(context: Context) { + val intent = Intent(context, LocationReachedService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, LocationReachedService::class.java) + context.stopService(intent) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopTracking() + return START_NOT_STICKY + } + + isAlarmTriggered = false + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildInitialNotification()) + startTracking() + + return START_STICKY + } + + private fun startTracking() { + trackingJob?.cancel() + trackingJob = serviceScope.launch { + while (isActive) { + val alarm = repository.getAlarm() + if (alarm.isEnabled && alarm.latitude != 0.0 && alarm.longitude != 0.0) { + updateProgress(alarm) + } else { + stopSelf() + break + } + delay(10000) // Update every 10 seconds for better responsiveness + } + } + } + + private fun stopTracking() { + val alarm = repository.getAlarm() + repository.saveAlarm(alarm.copy(isEnabled = false)) + stopSelf() + } + + @android.annotation.SuppressLint("MissingPermission") + private fun updateProgress(alarm: com.sameerasw.essentials.domain.model.LocationAlarm) { + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .addOnSuccessListener { location -> + location?.let { + val distance = calculateDistance(it.latitude, it.longitude, alarm.latitude, alarm.longitude) + val distanceKm = distance / 1000f + + // Watchdog: If we reached the radius but geofence didn't trigger + if (distance <= alarm.radius && !isAlarmTriggered) { + isAlarmTriggered = true + triggerArrivalAlarm() + } + + updateNotification(distanceKm) + } + } + } + + private fun triggerArrivalAlarm() { + val channelId = "location_reached_channel" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + getString(R.string.feat_location_reached_title), + NotificationManager.IMPORTANCE_HIGH + ).apply { + enableLights(true) + enableVibration(true) + } + notificationManager.createNotificationChannel(channel) + } + + val fullScreenIntent = Intent(this, com.sameerasw.essentials.ui.activities.LocationAlarmActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION + } + val fullScreenPendingIntent = PendingIntent.getActivity( + this, 0, fullScreenIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.rounded_navigation_24) + .setContentTitle(getString(R.string.location_reached_notification_title)) + .setContentText(getString(R.string.location_reached_notification_desc)) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setFullScreenIntent(fullScreenPendingIntent, true) + .setAutoCancel(true) + .build() + + notificationManager.notify(ALARM_NOTIFICATION_ID, notification) + } + + private fun updateNotification(distanceKm: Float) { + val startDist = repository.getStartDistance() + val progressPercent = if (startDist > 0) { + ((1.0f - (distanceKm * 1000f / startDist)) * 100).toInt().coerceIn(0, 100) + } else 0 + + val notification = buildOngoingNotification(distanceKm, progressPercent) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun buildInitialNotification(): Notification { + return buildOngoingNotification(null, 0) + } + + private fun buildOngoingNotification(distanceKm: Float?, progress: Int): Notification { + val stopIntent = Intent(this, LocationReachedService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val mainIntent = Intent(this, MainActivity::class.java).apply { + putExtra("feature", "Location reached") + } + val mainPendingIntent = PendingIntent.getActivity(this, 0, mainIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val distanceText = distanceKm?.let { + if (it < 1.0) String.format("%d m", (it * 1000).toInt()) + else String.format("%.1f km", it) + } ?: "Calculating..." + + val contentText = "$distanceText remaining ($progress%)" + + if (Build.VERSION.SDK_INT >= 35) { + val builder = Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_navigation_24) + .setContentTitle("Travel Alarm active") + .setContentText(contentText) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_SERVICE) + .setContentIntent(mainPendingIntent) + .addAction(Notification.Action.Builder( + Icon.createWithResource(this, R.drawable.rounded_power_settings_new_24), + "Stop Tracking", stopPendingIntent).build()) + + if (Build.VERSION.SDK_INT >= 36) { + try { + val progressStyle = Notification.ProgressStyle() + .setStyledByProgress(true) + .setProgress(progress) + .setProgressTrackerIcon(Icon.createWithResource(this, R.drawable.rounded_navigation_24)) + builder.setStyle(progressStyle) + } catch (_: Throwable) { + builder.setProgress(100, progress, false) + } + } else { + builder.setProgress(100, progress, false) + } + + try { + val extras = android.os.Bundle() + extras.putBoolean("android.requestPromotedOngoing", true) + extras.putBoolean("android.substituteContextualActions", true) + distanceKm?.let { extras.putString("android.shortCriticalText", String.format("%.0f%%", progress.toFloat())) } + builder.addExtras(extras) + + builder.javaClass.getMethod("setRequestPromotedOngoing", Boolean::class.javaPrimitiveType) + .invoke(builder, true) + + distanceKm?.let { + builder.javaClass.getMethod("setShortCriticalText", CharSequence::class.java) + .invoke(builder, String.format("%.0f%%", progress.toFloat())) + } + } catch (_: Throwable) {} + + return builder.build() + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_navigation_24) + .setContentTitle("Travel Alarm active") + .setContentText(contentText) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(mainPendingIntent) + .setProgress(100, progress, false) + .addAction(R.drawable.rounded_power_settings_new_24, "Stop Tracking", stopPendingIntent) + + val extras = android.os.Bundle() + extras.putBoolean("android.requestPromotedOngoing", true) + distanceKm?.let { extras.putString("android.shortCriticalText", String.format("%d%%", progress)) } + builder.addExtras(extras) + + return builder.build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Travel Progress", + NotificationManager.IMPORTANCE_HIGH // Increased importance + ).apply { + description = "Shows real-time distance to destination" + setShowBadge(false) + setLockscreenVisibility(Notification.VISIBILITY_PUBLIC) // Ensure it's visible on lockscreen + setSound(null, null) + enableVibration(false) + } + notificationManager.createNotificationChannel(channel) + } + } + + private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float { + val r = 6371e3 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2) * sin(dLon / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return (r * c).toFloat() + } + + override fun onDestroy() { + trackingJob?.cancel() + serviceScope.cancel() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt new file mode 100644 index 00000000..4e1aac87 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt @@ -0,0 +1,227 @@ +package com.sameerasw.essentials.ui.activities + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowCompat +import androidx.activity.compose.setContent +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.LocationReachedRepository +import com.sameerasw.essentials.services.LocationReachedService +import kotlinx.coroutines.delay + +class LocationAlarmActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() + showWhenLockedAndTurnScreenOn() + super.onCreate(savedInstanceState) + + setContent { + com.sameerasw.essentials.ui.theme.EssentialsTheme { + LocationAlarmScreen(onFinish = { + stopAlarmAndFinish() + }) + } + } + + startUrgentVibration() + } + + override fun onStop() { + super.onStop() + stopAlarmAndFinish() + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + stopAlarmAndFinish() + } + + private fun showWhenLockedAndTurnScreenOn() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + // Keyguard dismissal + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD) + } + + // Keep screen on + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + private fun startUrgentVibration() { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val pattern = longArrayOf(0, 500, 200, 500, 200, 1000) + val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255) + vibrator.vibrate(VibrationEffect.createWaveform(pattern, amplitudes, 1)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(longArrayOf(0, 500, 200, 500), 0) + } + } + + private fun stopAlarmAndFinish() { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + try { + vibrator.cancel() + } catch (e: Exception) { + e.printStackTrace() + } + + // Disable alarm in repo + val repo = LocationReachedRepository(this) + val alarm = repo.getAlarm() + repo.saveAlarm(alarm.copy(isEnabled = false)) + + // Stop the progress service + LocationReachedService.stop(this) + + if (!isFinishing) { + finish() + } + } +} + +// @androidx.compose.ui.tooling.preview.Preview(showBackground = true) +// @Composable +// fun LocationAlarmScreenPreview() { +// LocationAlarmScreen(onFinish = {}) +// } + +@Composable +fun LocationAlarmScreen(onFinish: () -> Unit) { + val infiniteTransition = rememberInfiniteTransition(label = "alarm") + + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(200.dp) + .scale(scale) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_location_on_24), + contentDescription = null, + modifier = Modifier.size(100.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Destination Nearby", + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Black, + letterSpacing = 2.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = "Prepare to get off", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(80.dp)) + + Button( + onClick = onFinish, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(64.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_mobile_check_24), + contentDescription = null + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Dismiss", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold) + ) + } + } + } +} + +// preview +@androidx.compose.ui.tooling.preview.Preview(showBackground = true) +@Composable +fun LocationAlarmScreenPreview() { + LocationAlarmScreen(onFinish = {}) +} \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt new file mode 100644 index 00000000..48e7304f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt @@ -0,0 +1,298 @@ +package com.sameerasw.essentials.ui.composables.configs + +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.material3.LoadingIndicator +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.android.material.loadingindicator.LoadingIndicator +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.components.cards.IconToggleItem +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.viewmodels.LocationReachedViewModel +import com.sameerasw.essentials.viewmodels.MainViewModel +import com.sameerasw.essentials.domain.model.LocationAlarm + +@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LocationReachedSettingsUI( + mainViewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightSetting: String? = null +) { + val context = LocalContext.current + val locationViewModel: LocationReachedViewModel = viewModel() + val alarm by locationViewModel.alarm + val distance by locationViewModel.currentDistance + val isProcessing by locationViewModel.isProcessingCoordinates + val startDistance by locationViewModel.startDistance + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isProcessing) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoadingIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Processing location...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (alarm.latitude != 0.0 && alarm.longitude != 0.0) { + // Destination Set State + RoundedCardContainer( + modifier = Modifier, + cornerRadius = 24.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp) + ) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + if (alarm.isEnabled) { + // TRACKING STATE + val distanceText = distance?.let { + if (it < 1000) "${it.toInt()}m" else String.format("%.1fkm", it / 1000f) + } ?: "Calculating..." + + Text( + text = "DISTANCE REMAINING", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = distanceText, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + + if (distance != null && startDistance > 0) { + val progress = (1.0f - (distance!! / startDistance)).coerceIn(0.0f, 1.0f) + Spacer(modifier = Modifier.height(24.dp)) + + LinearWavyProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(12.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer, + wavelength = 20.dp, + amplitude = { 1.0f } // Normalized amplitude + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { locationViewModel.stopTracking() }, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.error + ), + shape = androidx.compose.foundation.shape.CircleShape + ) { + Icon(painterResource(R.drawable.rounded_pause_24), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Stop Tracking") + } + + } else { + // READY STATE (Not Tracking) + Icon( + painter = painterResource(id = R.drawable.rounded_my_location_24), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Destination Ready", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${alarm.latitude}, ${alarm.longitude}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { locationViewModel.startTracking() }, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + shape = androidx.compose.foundation.shape.CircleShape + ) { + Icon(painterResource(R.drawable.rounded_play_arrow_24), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Start Tracking") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Secondary Actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + val gmmIntentUri = Uri.parse("geo:${alarm.latitude},${alarm.longitude}") + val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) + mapIntent.setPackage("com.google.android.apps.maps") + context.startActivity(mapIntent) + }, + modifier = Modifier.weight(1f), + shape = androidx.compose.foundation.shape.CircleShape, + colors = ButtonDefaults.filledTonalButtonColors() + ) { + Icon(painterResource(R.drawable.rounded_map_24), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("View Map") + } + + Button( + onClick = { locationViewModel.clearAlarm() }, + modifier = Modifier.weight(1f), + shape = androidx.compose.foundation.shape.CircleShape, + colors = ButtonDefaults.filledTonalButtonColors() + ) { + Icon(painterResource(R.drawable.rounded_delete_24), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Clear") + } + } + } + } + } else { + // Empty State + RoundedCardContainer( + modifier = Modifier.fillMaxWidth(), + cornerRadius = 24.dp + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_add_location_alt_24), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.surfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No Destination", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Share a location from Google Maps to start.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + val gmmIntentUri = Uri.parse("geo:0,0?q=") + val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) + mapIntent.setPackage("com.google.android.apps.maps") + context.startActivity(mapIntent) + }, + shape = androidx.compose.foundation.shape.CircleShape, + colors = ButtonDefaults.filledTonalButtonColors() + ) { + Text("Open Maps") + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Alarm Radius: ${alarm.radius}m", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer( + modifier = Modifier, + cornerRadius = 24.dp + ) { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceBright).padding(16.dp)) { + Slider( + value = alarm.radius.toFloat(), + onValueChange = { newVal -> + locationViewModel.updateAlarm(alarm.copy(radius = newVal.toInt())) + }, + valueRange = 100f..5000f, + steps = 49 + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + val isFSIGranted by mainViewModel.isFullScreenIntentPermissionGranted + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !isFSIGranted) { + RoundedCardContainer( + modifier = Modifier.background( + color = MaterialTheme.colorScheme.errorContainer, + shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp) + ), + cornerRadius = 24.dp + ) { + IconToggleItem( + title = "Full-Screen Alarm Permission", + description = "Required to wake your device upon arrival. Tap to grant.", + isChecked = false, + onCheckedChange = { mainViewModel.requestFullScreenIntentPermission(context) }, + iconRes = R.drawable.rounded_info_24, + showToggle = false + ) + } + } + } +} + diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt index c93b29eb..e76763a3 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt @@ -104,4 +104,31 @@ object PermissionUtils { // Fallback or ignore } } + + fun hasLocationPermission(context: Context): Boolean { + return androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_FINE_LOCATION + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + + fun hasBackgroundLocationPermission(context: Context): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + true + } + } + + fun canUseFullScreenIntent(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + nm.canUseFullScreenIntent() + } else { + true + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt new file mode 100644 index 00000000..3291328b --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt @@ -0,0 +1,245 @@ +package com.sameerasw.essentials.viewmodels + +import android.app.Application +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.sameerasw.essentials.data.repository.LocationReachedRepository +import com.sameerasw.essentials.domain.model.LocationAlarm +import com.sameerasw.essentials.services.LocationReachedService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import kotlin.math.* + +class LocationReachedViewModel(application: Application) : AndroidViewModel(application) { + private val repository = LocationReachedRepository(application) + private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(application) + + var alarm = mutableStateOf(repository.getAlarm()) + private set + + var isProcessingCoordinates = mutableStateOf(false) + private set + + var currentDistance = mutableStateOf(null) + private set + + var startDistance = mutableStateOf(repository.getStartDistance()) + private set + + init { + startDistanceTracking() + + // Observe shared state for real-time updates across activities + viewModelScope.launch { + LocationReachedRepository.isProcessing.collect { + isProcessingCoordinates.value = it + } + } + + viewModelScope.launch { + LocationReachedRepository.alarmFlow.collect { newAlarm -> + newAlarm?.let { + alarm.value = it + // Start distance might need refresh if destination changed + } + } + } + } + + fun clearAlarm() { + val clearedAlarm = LocationAlarm(0.0, 0.0, 1000, false) + alarm.value = clearedAlarm + startDistance.value = 0f + repository.saveAlarm(clearedAlarm) + repository.saveStartDistance(0f) + LocationReachedService.stop(getApplication()) + currentDistance.value = null + } + + fun startTracking() { + val currentAlarm = alarm.value + if (currentAlarm.latitude != 0.0 && currentAlarm.longitude != 0.0) { + val enabledAlarm = currentAlarm.copy(isEnabled = true) + alarm.value = enabledAlarm + repository.saveAlarm(enabledAlarm) + LocationReachedService.start(getApplication()) + + // Refreshed start distance logic + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .addOnSuccessListener { location -> + location?.let { + val dist = calculateDistance( + it.latitude, it.longitude, + enabledAlarm.latitude, enabledAlarm.longitude + ) + startDistance.value = dist + repository.saveStartDistance(dist) + } + } + } + } + + fun stopTracking() { + val currentAlarm = alarm.value + val disabledAlarm = currentAlarm.copy(isEnabled = false) + alarm.value = disabledAlarm + repository.saveAlarm(disabledAlarm) + LocationReachedService.stop(getApplication()) + // Keep start distance for potential restart? Or maybe just keep coordinates. + // User said "keep last track in memory (only destination)". + } + + private fun startDistanceTracking() { + viewModelScope.launch { + while (true) { + // Tracking should only happen if enabled? Or just if coordinates exist? + // Logic: Distance displayed in UI needs coords. Service needs enabled. + if (alarm.value.latitude != 0.0 && alarm.value.longitude != 0.0) { + updateCurrentDistance() + } else { + currentDistance.value = null + } + delay(10000) // Update every 10 seconds + } + } + } + + @android.annotation.SuppressLint("MissingPermission") + private fun updateCurrentDistance() { + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .addOnSuccessListener { location -> + location?.let { + val distance = calculateDistance( + it.latitude, it.longitude, + alarm.value.latitude, alarm.value.longitude + ) + currentDistance.value = distance + } + } + } + + private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float { + val r = 6371e3 // Earth's radius in meters + val phi1 = lat1 * PI / 180 + val phi2 = lat2 * PI / 180 + val deltaPhi = (lat2 - lat1) * PI / 180 + val deltaLambda = (lon2 - lon1) * PI / 180 + + val a = sin(deltaPhi / 2).pow(2) + + cos(phi1) * cos(phi2) * + sin(deltaLambda / 2).pow(2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return (r * c).toFloat() + } + + fun updateAlarm(newAlarm: LocationAlarm) { + alarm.value = newAlarm + repository.saveAlarm(newAlarm) + + // If updating radius while enabled, ensure service stays up or is updated? + // Service just reads from repo loop, so saving is enough. + } + + fun handleIntent(intent: android.content.Intent): Boolean { + val action = intent.action + val type = intent.type + val data = intent.data + + android.util.Log.d("LocationReachedVM", "handleIntent: action=$action, type=$type, data=$data") + + val textToParse = when { + action == android.content.Intent.ACTION_SEND && type == "text/plain" -> { + intent.getStringExtra(android.content.Intent.EXTRA_TEXT) + } + action == android.content.Intent.ACTION_VIEW && data?.scheme == "geo" -> { + data.toString() + } + action == android.content.Intent.ACTION_VIEW && (data?.host?.contains("google.com") == true || data?.host?.contains("goo.gl") == true) -> { + data.toString() + } + else -> null + } + + if (textToParse == null) return false + + // Check if it's a shortened URL that needs resolution + if (textToParse.contains("maps.app.goo.gl") || textToParse.contains("goo.gl/maps")) { + resolveAndParse(textToParse) + return true // Navigate to settings while resolving + } + + return tryParseAndSet(textToParse) + } + + private fun tryParseAndSet(text: String): Boolean { + // Broad regex for coordinates: looks for two floats separated by a comma + // Supports: "40.7127, -74.0059", "geo:40.7127,-74.0059", "@40.7127,-74.0059", "q=40.7127,-74.0059" + val commaRegex = Regex("(-?\\d+\\.\\d+)\\s*,\\s*(-?\\d+\\.\\d+)") + + // Pattern for Google Maps data URLs: !3d40.7127!4d-74.0059 + val dataRegex = Regex("!3d(-?\\d+\\.\\d+)!4d(-?\\d+\\.\\d+)") + + val match = commaRegex.find(text) ?: dataRegex.find(text) + + if (match != null) { + val lat = match.groupValues[1].toDoubleOrNull() ?: 0.0 + val lng = match.groupValues[2].toDoubleOrNull() ?: 0.0 + + if (lat != 0.0 && lng != 0.0) { + android.util.Log.d("LocationReachedVM", "Parsed coordinates: $lat, $lng") + // Staging mode: don't enable yet + updateAlarm(alarm.value.copy(latitude = lat, longitude = lng, isEnabled = false)) + android.widget.Toast.makeText(getApplication(), "Destination set: $lat, $lng", android.widget.Toast.LENGTH_SHORT).show() + repository.setIsProcessing(false) + return true + } + } + android.util.Log.d("LocationReachedVM", "No coordinates found in text: $text") + repository.setIsProcessing(false) + return false + } + + private fun resolveAndParse(shortUrl: String) { + repository.setIsProcessing(true) + viewModelScope.launch { + val resolvedUrl = withContext(Dispatchers.IO) { + try { + val url = URL(shortUrl) + val connection = url.openConnection() as HttpURLConnection + connection.instanceFollowRedirects = false + connection.connect() + val location = connection.getHeaderField("Location") + connection.disconnect() + location ?: shortUrl + } catch (e: Exception) { + android.util.Log.e("LocationReachedVM", "Error resolving URL", e) + shortUrl + } + } + android.util.Log.d("LocationReachedVM", "Resolved URL: $resolvedUrl") + if (!tryParseAndSet(resolvedUrl)) { + // Additional check for @lat,lng which might not have spaces or exactly match the above + val pathRegex = Regex("@(-?\\d+\\.\\d+),(-?\\d+\\.\\d+)") + val pathMatch = pathRegex.find(resolvedUrl) + if (pathMatch != null) { + val lat = pathMatch.groupValues[1].toDoubleOrNull() ?: 0.0 + val lng = pathMatch.groupValues[2].toDoubleOrNull() ?: 0.0 + if (lat != 0.0 && lng != 0.0) { + // Staging mode: don't enable yet + updateAlarm(alarm.value.copy(latitude = lat, longitude = lng, isEnabled = false)) + android.widget.Toast.makeText(getApplication(), "Destination set: $lat, $lng", android.widget.Toast.LENGTH_SHORT).show() + } + } + repository.setIsProcessing(false) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 853200e3..8b696683 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -80,6 +80,9 @@ class MainViewModel : ViewModel() { val flashlightLastIntensity = mutableStateOf(1) val isFlashlightPulseEnabled = mutableStateOf(false) val isFlashlightPulseFacedownOnly = mutableStateOf(true) + val isLocationPermissionGranted = mutableStateOf(false) + val isBackgroundLocationPermissionGranted = mutableStateOf(false) + val isFullScreenIntentPermissionGranted = mutableStateOf(false) @@ -171,6 +174,9 @@ class MainViewModel : ViewModel() { isOverlayPermissionGranted.value = PermissionUtils.canDrawOverlays(context) isNotificationLightingAccessibilityEnabled.value = PermissionUtils.isNotificationLightingAccessibilityServiceEnabled(context) isDefaultBrowserSet.value = PermissionUtils.isDefaultBrowser(context) + isLocationPermissionGranted.value = PermissionUtils.hasLocationPermission(context) + isBackgroundLocationPermissionGranted.value = PermissionUtils.hasBackgroundLocationPermission(context) + isFullScreenIntentPermissionGranted.value = PermissionUtils.canUseFullScreenIntent(context) settingsRepository.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) settingsRepository.registerOnSharedPreferenceChangeListener(preferenceChangeListener) @@ -608,6 +614,24 @@ class MainViewModel : ViewModel() { ) } + fun requestLocationPermission(activity: androidx.activity.ComponentActivity) { + androidx.core.app.ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + 1003 + ) + } + + fun requestBackgroundLocationPermission(activity: androidx.activity.ComponentActivity) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + androidx.core.app.ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + 1004 + ) + } + } + fun requestNotificationPermission(activity: androidx.activity.ComponentActivity) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { androidx.core.app.ActivityCompat.requestPermissions( @@ -618,6 +642,23 @@ class MainViewModel : ViewModel() { } } + fun requestFullScreenIntentPermission(context: Context) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + try { + val intent = Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply { + data = android.net.Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + // Fallback to special app access + val intent = Intent(Settings.ACTION_CONDITION_PROVIDER_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + } + } + private fun hasNotificationListenerPermission(context: Context): Boolean { return PermissionUtils.hasNotificationListenerPermission(context) } diff --git a/app/src/main/res/drawable/rounded_add_location_alt_24.xml b/app/src/main/res/drawable/rounded_add_location_alt_24.xml new file mode 100644 index 00000000..a759d6ce --- /dev/null +++ b/app/src/main/res/drawable/rounded_add_location_alt_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_delete_24.xml b/app/src/main/res/drawable/rounded_delete_24.xml new file mode 100644 index 00000000..7a1c4d87 --- /dev/null +++ b/app/src/main/res/drawable/rounded_delete_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_location_on_24.xml b/app/src/main/res/drawable/rounded_location_on_24.xml new file mode 100644 index 00000000..41120959 --- /dev/null +++ b/app/src/main/res/drawable/rounded_location_on_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_map_24.xml b/app/src/main/res/drawable/rounded_map_24.xml new file mode 100644 index 00000000..97b61e1b --- /dev/null +++ b/app/src/main/res/drawable/rounded_map_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_my_location_24.xml b/app/src/main/res/drawable/rounded_my_location_24.xml new file mode 100644 index 00000000..bc90c2b7 --- /dev/null +++ b/app/src/main/res/drawable/rounded_my_location_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_pause_24.xml b/app/src/main/res/drawable/rounded_pause_24.xml new file mode 100644 index 00000000..76350ba6 --- /dev/null +++ b/app/src/main/res/drawable/rounded_pause_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c26bee01..aa0fc3b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -708,4 +708,24 @@ Copy to Clipboard Essentials Bug Report Send via + + + Location reached + Get notified when you arrive at a specific destination. + Destination + Set Destination + Tracking: %1$.4f, %2$.4f + No destination set + Open your map app, pick a location, and share it to Essentials. + Radius: %d m + Distance to trigger the alarm + Enable notification + Location + Used to detect arrival at your destination. + Background Location + Required to monitor your arrival while the app is closed or the screen is off. + Destination Reached! + You have arrived at your destination. + Currently %1$.1f km away + Clear Destination \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2b961481..14855b48 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -14,4 +14,10 @@ true @null + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7854ecc..ee5dedc0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ kotlin = "2.3.0" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" -espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.0" composeBom = "2024.10.01" @@ -14,12 +13,13 @@ hiddenapibypass = "4.3" foundationLayout = "1.10.0" ui = "1.10.0" foundation = "1.10.0" +playServicesLocation = "21.3.0" +material = "1.13.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -38,6 +38,8 @@ hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypa androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }