From 98d46d75154a8f4cf638daa24e68701e7e269c60 Mon Sep 17 00:00:00 2001 From: Sidharth Gupta Date: Mon, 2 Dec 2024 17:47:26 -0500 Subject: [PATCH] Redone location permission settings with user intervention. This architecture will help in FGService control as well --- .gitignore | 2 + app/build.gradle | 3 + app/proguard-rules.pro | 8 +- app/src/main/AndroidManifest.xml | 1 + .../java/me/itissid/privyloci/MainActivity.kt | 309 +++++++++++++----- .../java/me/itissid/privyloci/Permissions.kt | 3 + .../me/itissid/privyloci/UserPreferences.kt | 92 ++++++ .../itissid/privyloci/data/DataStoreModule.kt | 29 ++ .../service/PrivyForegroundService.kt | 26 +- .../privyloci/service/ServiceStoppedWorker.kt | 44 ++- .../me/itissid/privyloci/ui/AdaptiveIcon.kt | 4 +- .../ui/LocationPermissionRationaleDialogue.kt | 16 +- .../itissid/privyloci/ui/MainScreenPreview.kt | 11 +- .../java/me/itissid/privyloci/util/Logger.kt | 3 + .../privyloci/viewmodels/MainViewModel.kt | 162 +++++++++ 15 files changed, 609 insertions(+), 104 deletions(-) create mode 100644 app/src/main/java/me/itissid/privyloci/Permissions.kt create mode 100644 app/src/main/java/me/itissid/privyloci/UserPreferences.kt create mode 100644 app/src/main/java/me/itissid/privyloci/data/DataStoreModule.kt create mode 100644 app/src/main/java/me/itissid/privyloci/viewmodels/MainViewModel.kt diff --git a/.gitignore b/.gitignore index b88a2a4..4836314 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,5 @@ concatenated_code.txt docs/FG Evolve.rtf docs/LocationPermissionEvolve.md +app/src/main/java/me/itissid/privyloci/ui/Scratch.kt + diff --git a/app/build.gradle b/app/build.gradle index 2215195..e035aae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.animation:animation' implementation 'androidx.compose.runtime:runtime' + implementation 'androidx.compose.runtime:runtime-livedata' // HILT related dependencies implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' @@ -90,6 +91,8 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2' implementation 'com.google.android.gms:play-services-location:21.3.0' + implementation 'androidx.datastore:datastore-preferences:1.1.1' + def work_version = "2.9.1" // (Java only) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..22dae6c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,10 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +-assumenosideeffects class android.util.Log { + public static int d(...); +} +-assumenosideeffects class android.util.Log { + public static int v(...); +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13ef51f..bf43d9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ diff --git a/app/src/main/java/me/itissid/privyloci/MainActivity.kt b/app/src/main/java/me/itissid/privyloci/MainActivity.kt index 9679ae1..f970757 100644 --- a/app/src/main/java/me/itissid/privyloci/MainActivity.kt +++ b/app/src/main/java/me/itissid/privyloci/MainActivity.kt @@ -2,14 +2,13 @@ package me.itissid.privyloci import android.Manifest import android.app.Activity -import android.app.Service -import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle -import android.telephony.ServiceState -import android.util.Log +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -39,6 +38,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.compose.NavHost import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -53,13 +53,13 @@ import me.itissid.privyloci.ui.PlacesAndAssetsScreen import me.itissid.privyloci.ui.theme.PrivyLociTheme import dagger.hilt.android.AndroidEntryPoint import me.itissid.privyloci.data.DataProvider.processAppContainers -import me.itissid.privyloci.service.PrivyForegroundService -import me.itissid.privyloci.service.ServiceStateHolder -import me.itissid.privyloci.service.startPrivyForegroundService -import me.itissid.privyloci.service.stopPrivyForegroundService +import me.itissid.privyloci.service.FG_NOTIFICATION_DISMISSED import me.itissid.privyloci.ui.AdaptiveIcon import me.itissid.privyloci.ui.LocationPermissionRationaleDialogue import me.itissid.privyloci.util.Logger +import me.itissid.privyloci.viewmodels.ForegroundPermissionRationaleState +import me.itissid.privyloci.viewmodels.MainViewModel +import me.itissid.privyloci.viewmodels.RationaleState // TODO(Sid): Replace with real data after demo. data class MockData( @@ -68,40 +68,72 @@ data class MockData( val subscriptions: List ) -const val TAG = "me.itissid.privyloci.MainActivity" +const val TAG = "MainActivity" + // move to jetpack compose @AndroidEntryPoint class MainActivity : ComponentActivity() { - + private val wasFGNotificationDismissed = mutableStateOf(false) + private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - -// val locationPermissionState = LocationPermissionState(this) { -// if (it.hasPermission()) { -// Logger.w("MainActivity", "Location permission granted") -// } else { -// Logger.w("MainActivity", "Location permission denied") -// } -// } -// val (placesList, assetsList, subscriptionsList) = DataProvider.getData() -// val places = placesList + assetsList -// val userSubscriptions = subscriptionsList.filter { it.type == SubscriptionType.USER } -// val appContainers = DataProvider.processAppContainers(subscriptionsList) setContent { PrivyLociTheme { - MainScreenWrapper() + MainScreenWrapper( + wasFGNotificationDismissed = wasFGNotificationDismissed.value, + viewModel = viewModel + ) } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + intent?.let { + Logger.v(TAG, "Intent received: $it.") + val isServiceStopped = it.getBooleanExtra(FG_NOTIFICATION_DISMISSED, false) + Logger.v(TAG, "handleIntent: FG_NOTIF_DISMISSED = $isServiceStopped") + + wasFGNotificationDismissed.value = isServiceStopped + } + } } @OptIn(ExperimentalPermissionsApi::class) @Composable -fun MainScreenWrapper() { +fun MainScreenWrapper(wasFGNotificationDismissed: Boolean = false, viewModel: MainViewModel) { val context = LocalContext.current as Activity + val lifecycleOwner = LocalLifecycleOwner.current + - var rationaleState by remember { mutableStateOf(false) } + var fgPermissionRationaleState by + remember { mutableStateOf(null) } + var fgRunning by remember { mutableStateOf(false) } + + val userVisitedPermissionLauncherPreference by viewModel.userVisitedPermissionLauncher.collectAsState() + val userPausedLocationCollection by viewModel.userPausedLocationCollection.collectAsState() + + viewModel.isServiceRunning.observe(lifecycleOwner) { isRunning -> + fgRunning = isRunning + } + + + var fgServiceNotificationUserDismissed by remember(wasFGNotificationDismissed) { + mutableStateOf( + wasFGNotificationDismissed + ) + } + Logger.v( + TAG, + "wasFGNotificationDismissed: $wasFGNotificationDismissed, fgServiceNotificationUserDismissed: $fgServiceNotificationUserDismissed" + ) + var deactivateFGByUser by remember { mutableStateOf(false) } // TODO: Encapsulate the permision code in its own class. @@ -112,51 +144,152 @@ fun MainScreenWrapper() { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.POST_NOTIFICATIONS // Add this permission ) - ) + ) { it -> Logger.i(TAG, it.entries.joinToString(separator = "\n")) } + + if (fgRunning) { + Logger.v(TAG, "Privy FG Service is running") + } else { + Logger.v(TAG, "Privy FG Service is not running") + } if (!foregroundLocationPermissionState.allPermissionsGranted) { - Log.d(TAG, "PERMISSION NOT GRANTED") + Logger.v(TAG, "PERMISSION NOT GRANTED") } else { - Log.d(TAG, "PERMISSION GRANTED") + Logger.v(TAG, "PERMISSION GRANTED") } if (foregroundLocationPermissionState.shouldShowRationale) { - Log.d(TAG, "SHOULD SHOW RATIONALE") + Logger.v(TAG, "SHOULD SHOW RATIONALE") } else { - Log.d(TAG, "SHOULD NOT SHOW RATIONALE") + Logger.v(TAG, "SHOULD NOT SHOW RATIONALE") } - if (rationaleState) { - Log.d(TAG, "RATIONALE STATE") + if (fgPermissionRationaleState == null) { + Logger.v(TAG, "RATIONALE STATE") } else { - Log.d(TAG, "NO RATIONALE STATE") - } - - // Modify the rationalState in the click handler - val onLocationIconClick = { - if (foregroundLocationPermissionState.shouldShowRationale) { - Log.w(TAG, "Setting rationale state to true") - rationaleState = true - } else { - Log.w(TAG, "Launching multiple permission request") - foregroundLocationPermissionState.launchMultiplePermissionRequest() - // TODO: The system can choose to ignore this request. Raise a warning for the user. - // foregroundLocationPermissionState.shouldShowRationale == false, foregroundLocationPermissionState.isGranted == false, - } + Logger.v(TAG, "NO RATIONALE STATE") } - // read the state modified in the click handler - if (rationaleState) { + fgPermissionRationaleState?.let { LocationPermissionRationaleDialogue( onConfirm = { - Log.w(TAG, "Launching multiple permission request from onConfirm") - foregroundLocationPermissionState.launchMultiplePermissionRequest() - rationaleState = false + Logger.w(TAG, "Launching multiple permission request from onConfirm") + when (it.reason) { + RationaleState.LOCATION_PERMISSION_RATIONALE_SHOULD_BE_SHOWN -> { + try { + foregroundLocationPermissionState.launchMultiplePermissionRequest() + // TODO: At this point should ALWAYS launch the permissions since the RationaleState is set to this only if shouldShowRationale is true. + // But testing is needed. Setting this user preference guards against trying to repeatedly launch the permission request, because in android + // 14 it does nothing. + viewModel.setUserVisitedPermissionLauncherPreference(true) // + } finally { + fgPermissionRationaleState = null + } + } + + RationaleState.MULTIPLE_PERMISSIONS_SHOULD_BE_LAUNCHED -> { + try { + foregroundLocationPermissionState.launchMultiplePermissionRequest() + viewModel.setUserVisitedPermissionLauncherPreference(true) // + } finally { + fgPermissionRationaleState = null + } + } + + RationaleState.VISIT_SETTINGS -> { + try { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } finally { + fgPermissionRationaleState = null + } + } + + RationaleState.PAUSE_LOCATION_COLLECTION -> { + // TODO: set the pause flag that shuts down location collection. + try { + viewModel.setUserPausedLocationCollection(true) + } finally { + fgPermissionRationaleState = null + } + } + + RationaleState.RESUME_LOCATION_COLLECTION -> { + try { + viewModel.setUserPausedLocationCollection(false) + } finally { + fgPermissionRationaleState = null + } + } + } }, onDismiss = { - rationaleState = false - } + Logger.v(TAG, "User dismissed the rationale dialogue") + fgPermissionRationaleState = null + }, + message = it.rationaleText, + proceedText = it.proceedButtonText, + dismissText = it.dismissButtonText ) } + + // Modify the rationalState in the spirit of the Unidirectional flow model + val onLocationIconClick = { + if (!foregroundLocationPermissionState.allPermissionsGranted) { + if (foregroundLocationPermissionState.shouldShowRationale) { + Logger.v(TAG, "Setting rationale state to be shown") + fgPermissionRationaleState = ForegroundPermissionRationaleState( + RationaleState.LOCATION_PERMISSION_RATIONALE_SHOULD_BE_SHOWN, + rationaleText = "In order to use PrivyLoci's location features, please grant access by accepting the location permission dialog." + ) + viewModel.setUserVisitedPermissionLauncherPreference(false) // Remove the user preference since the system is allowing us to show the rationale. + } else { + if (!userVisitedPermissionLauncherPreference) {// User has not visited permission launcher code. + Logger.v(TAG, "Setting launch flag for multiple permission request") + fgPermissionRationaleState = + ForegroundPermissionRationaleState( + RationaleState.MULTIPLE_PERMISSIONS_SHOULD_BE_LAUNCHED, + rationaleText = "In order to use PrivyLoci's location feature, press 'Proceed' and grant the 'While in Use' location permissions." + ) + + } else { // + // Multiple permissions have been launched once. Now the user will always be directed to the settings when clicking this.. + Logger.v(TAG, "Setting rationale state to visit settings") + fgPermissionRationaleState = + ForegroundPermissionRationaleState( + RationaleState.VISIT_SETTINGS, + rationaleText = "In order to use PrivyLoci's location features, press 'Proceed', and in the settings and select 'Allow only while using the app'" + ) + } + } + } else { + // Granted location permission, we can choose to "pause" the collection of the location data and via a user dialogue. Set a preference flag. + // N2S: We can also choose to revoke the permissions, but that may make it too difficult for the user to reactivate it on Google's flavor of permission for devices. + // Alert dialogue to do this + if (!userPausedLocationCollection) { + fgPermissionRationaleState = + ForegroundPermissionRationaleState( + RationaleState.PAUSE_LOCATION_COLLECTION, + rationaleText = "You can Proceed App wide location collection by pressing proceed.", + proceedButtonText = "Pause", + dismissButtonText = "Dismiss" + ) + } else { + fgPermissionRationaleState = + ForegroundPermissionRationaleState( + RationaleState.RESUME_LOCATION_COLLECTION, + rationaleText = "You can resume App wide location collection by pressing proceed.", + proceedButtonText = "Resume", + dismissButtonText = "Dismiss" + ) + } + } + } + + /*End logic to deal with removed FG notification */ + // TODO: Consider using a viewmodel to get the data from the daos. val database = MainApplication.database @@ -165,8 +298,8 @@ fun MainScreenWrapper() { // Coro for the win! val places by placeTagDao.getAllPlaceTags().collectAsState(initial = emptyList()) val subscriptions by subscriptionDao.getAllSubscriptions().collectAsState(initial = emptyList()) - Log.d(TAG, "Places: ${places.size}") - Log.d(TAG, "Subscriptions: ${subscriptions.size}") + Logger.v(TAG, "Places: ${places.size}") + Logger.v(TAG, "Subscriptions: ${subscriptions.size}") val userSubscriptions = subscriptions.filter { it.type == SubscriptionType.USER } val appContainers = processAppContainers(subscriptions) @@ -174,26 +307,51 @@ fun MainScreenWrapper() { appContainers, userSubscriptions, places, - foregroundLocationPermissionState.allPermissionsGranted, + foregroundLocationPermissionState.allPermissionsGranted && !userPausedLocationCollection, onLocationIconClick ) - if (foregroundLocationPermissionState.allPermissionsGranted && !ServiceStateHolder.isServiceRunning) { - // TODO: On starting the service I want to show some of the data about how many subscriptions are active - // and being tracked. - startPrivyForegroundService(context) - } else if (!foregroundLocationPermissionState.allPermissionsGranted) { - // TODO: Warn the user after some time(probably like in a timer) that the service is not running - // because the permission is not granted. - stopPrivyForegroundService(context) - } +// if (foregroundLocationPermissionState.allPermissionsGranted) { +// // if the service is not started and the user has not dismissed the FG notification call the service +// // if the service is not started and the user has dismissed the the FG notification, show the user a dialogue which starts the FG service on confirmation. +// // TODO: On starting the service I want to show some of the data about how many subscriptions are active +// // and being tracked. +// if (!fgRunning) { +// if (fgServiceNotificationUserDismissed) { +// // User swipes left and dismisses the foreground service +// if (!deactivateFGByUser) { +// Logger.v(TAG, "Displaying the FG service notification dismissed dialogue") +// FGDismissedStoppedDialog(onDismiss = { +// Logger.v( +// TAG, +// "User dismissed the FG service notification and does not want to start it again." +// ) +// deactivateFGByUser = true +// }, onRestartService = { +// Logger.v(TAG, "Attempting to start the FG service again.") +// fgServiceNotificationUserDismissed = false +// startPrivyForegroundService(context) +// }) +// } +// } +//// } else { +//// // Service is not running due to some other reason like killed by the system +//// Logger.v(TAG, "Attempting to start the FG service as normal") +//// startPrivyForegroundService(context) +//// } +// } else { +// Logger.v(TAG, "Privy FG Service detected as already running") +// } +// } else if (!foregroundLocationPermissionState.allPermissionsGranted) { +// // call if the service is not stopped already. +// // TODO: Warn the user after some time(probably like in a timer) that the service is not running +// // because the permission is not granted. +// if (fgRunning) { +// stopPrivyForegroundService(context) +// } +// } } - -data class RationaleState( - val isInitialized: Boolean -) - @Composable fun MainScreen( appContainers: List, @@ -247,19 +405,10 @@ fun TopBar( TopAppBar( title = { Text("Privy Loci") }, actions = { - if (locationPermissionGranted) { IconButton(onClick = onLocationIconClick) { - AdaptiveIcon(locationPermissionGranted = true) + AdaptiveIcon(locationPermissionGranted = locationPermissionGranted) } Icon(Icons.Filled.Menu, contentDescription = "Menu") - } else { - IconButton(onClick = {/*TODO: Explainer dial that is dismissable*/ }) { - AdaptiveIcon(locationPermissionGranted = false) - } - Icon(Icons.Filled.Menu, contentDescription = "Menu") - - } - } ) } diff --git a/app/src/main/java/me/itissid/privyloci/Permissions.kt b/app/src/main/java/me/itissid/privyloci/Permissions.kt new file mode 100644 index 0000000..47b4e5d --- /dev/null +++ b/app/src/main/java/me/itissid/privyloci/Permissions.kt @@ -0,0 +1,3 @@ +package me.itissid.privyloci + +class Permissions \ No newline at end of file diff --git a/app/src/main/java/me/itissid/privyloci/UserPreferences.kt b/app/src/main/java/me/itissid/privyloci/UserPreferences.kt new file mode 100644 index 0000000..b016abd --- /dev/null +++ b/app/src/main/java/me/itissid/privyloci/UserPreferences.kt @@ -0,0 +1,92 @@ +package me.itissid.privyloci + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +// Per SO Answer: https://arc.net/l/quote/kebwdeqe +private val Context.privyLociDataStore by preferencesDataStore("user_preferences") + +@Singleton +class UserPreferences @Inject constructor( + @ApplicationContext context: Context +) { + private val dataStore: DataStore = context.privyLociDataStore + + val wasFGPerrmissionRationaleDismissed = dataStore.data.map { + it[fgPerrmissionRationaleDismissedKey] ?: false + } + val wasFGPersistentNotificationDismissed = dataStore.data.map { + it[fgPersistentNotificationDismissedKey] ?: false + } + val wasReactivateFGRationaleDismissed = dataStore.data.map { + it[reactivateFGRationaleDismissedKey] ?: false + } + + val userPausedLocationCollection = dataStore.data.map { + it[pausedLocationCollectionKey] ?: false + } + + val userVisitedPermissionLauncher = dataStore.data.map { + it[visitedPermissionLauncher] ?: false + } + + + suspend fun setFGPermissionRationaleDismissed(dismissed: Boolean) = + withContext(Dispatchers.IO) { + dataStore.edit { + it[fgPerrmissionRationaleDismissedKey] = dismissed + } + } + + suspend fun setFGPersistentNotificationDismissed(dismissed: Boolean) = + withContext(Dispatchers.IO) { + dataStore.edit { + it[fgPersistentNotificationDismissedKey] = dismissed + } + } + + suspend fun setReactivateFGRationaleDismissed(dismissed: Boolean) = + withContext(Dispatchers.IO) { + dataStore.edit { + it[reactivateFGRationaleDismissedKey] = dismissed + } + } + + suspend fun setUserVisitedPermissionLauncher(dismissed: Boolean) = + withContext(Dispatchers.IO) { + dataStore.edit { + it[visitedPermissionLauncher] = dismissed + } + } + + suspend fun setUserPausedLocationCollection(paused: Boolean) = + withContext(Dispatchers.IO) { + dataStore.edit { + it[pausedLocationCollectionKey] = paused + } + } + + private companion object { + val fgPerrmissionRationaleDismissedKey = + booleanPreferencesKey("fg_permission_rationale_dismissed") + val fgPersistentNotificationDismissedKey = + booleanPreferencesKey("fg_persistent_notification_dismissed") + val reactivateFGRationaleDismissedKey = + booleanPreferencesKey("reactivate_fg_rationale_dismissed") + + val visitedPermissionLauncher = booleanPreferencesKey("visited_permission_launcher") + val pausedLocationCollectionKey = booleanPreferencesKey("paused_location_collection") + // More keys for other sensors and permissions. + + } +} diff --git a/app/src/main/java/me/itissid/privyloci/data/DataStoreModule.kt b/app/src/main/java/me/itissid/privyloci/data/DataStoreModule.kt new file mode 100644 index 0000000..0bb12b6 --- /dev/null +++ b/app/src/main/java/me/itissid/privyloci/data/DataStoreModule.kt @@ -0,0 +1,29 @@ +package me.itissid.privyloci.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + // Add DataStore related bindings here +// private val Context.dataStore by preferencesDataStore("settings") + +// @Provides +// @Singleton +// fun providePreferencesDataStore(@ApplicationContext appContext: Context): DataStore = +// PreferenceDataStoreFactory.create( +// produceFIle = { appContext.preferencesDataStoreFile("user_preferences") } +// ) +// +} \ No newline at end of file diff --git a/app/src/main/java/me/itissid/privyloci/service/PrivyForegroundService.kt b/app/src/main/java/me/itissid/privyloci/service/PrivyForegroundService.kt index 298c9c0..f798c79 100644 --- a/app/src/main/java/me/itissid/privyloci/service/PrivyForegroundService.kt +++ b/app/src/main/java/me/itissid/privyloci/service/PrivyForegroundService.kt @@ -2,6 +2,7 @@ package me.itissid.privyloci.service import android.app.ForegroundServiceStartNotAllowedException import android.app.Notification +import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver @@ -11,10 +12,10 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import me.itissid.privyloci.MainActivity import me.itissid.privyloci.R @@ -26,10 +27,6 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.OutOfQuotaPolicy -object ServiceStateHolder { - var isServiceRunning = false -} - @AndroidEntryPoint class PrivyForegroundService : Service() { @Inject @@ -40,6 +37,8 @@ class PrivyForegroundService : Service() { companion object { const val CHANNEL_ID = "PrivyLociForegroundServiceChannel" + const val ACTION_SERVICE_STARTED = "me.itissid.privyloci.ACTION_SERVICE_STARTED" + const val ACTION_SERVICE_STOPPED = "me.itissid.privyloci.ACTION_SERVICE_STOPPED" } override fun onCreate() { @@ -55,11 +54,13 @@ class PrivyForegroundService : Service() { // Start the foreground service with notification startForegroundServiceWithNotification() - ServiceStateHolder.isServiceRunning = true + + val intent = Intent(ACTION_SERVICE_STARTED) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Logger.d(this::class.java.simpleName, "onStartCommand called") + Logger.d(this::class.java.simpleName, "Privy Loci onStartCommand called") // Handle any intents or actions here if needed // Service is already running, so return START_STICKY to keep it alive @@ -81,7 +82,8 @@ class PrivyForegroundService : Service() { e ) } finally { - ServiceStateHolder.isServiceRunning = false + val intent = Intent(ACTION_SERVICE_STOPPED) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } } @@ -152,15 +154,15 @@ class NotificationDismissedReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { // Have a viewmodel here set so that Logger.d( - "NotificationDismissedReciever", - "Peristent Notification dismissed, stopping FG Service" + "NotificationDismissedReceiver", + "Persistent Notification dismissed, stopping FG Service" ) context?.let { - // TODO: I decided to stop the foreground services en-masse but we could be more sparing. + // N2S: I decided to stop the foreground services en-masse but we could be more sparing. // We can send an intent to stop services that have private data only and let others run. stopPrivyForegroundService(it) } - + // TODO: Also update the user preference that the notification was dismissed. if (context != null) { val workRequest = OneTimeWorkRequestBuilder() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) diff --git a/app/src/main/java/me/itissid/privyloci/service/ServiceStoppedWorker.kt b/app/src/main/java/me/itissid/privyloci/service/ServiceStoppedWorker.kt index 6754628..b73270e 100644 --- a/app/src/main/java/me/itissid/privyloci/service/ServiceStoppedWorker.kt +++ b/app/src/main/java/me/itissid/privyloci/service/ServiceStoppedWorker.kt @@ -4,6 +4,10 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable import androidx.core.app.NotificationCompat import androidx.work.Worker import androidx.work.WorkerParameters @@ -11,6 +15,8 @@ import me.itissid.privyloci.MainActivity import me.itissid.privyloci.R import me.itissid.privyloci.service.PrivyForegroundService.Companion.CHANNEL_ID +const val FG_NOTIFICATION_DISMISSED = "FOREGROUND_NOTIFICATION_DISMISSED" +const val FG_NOTIFICATION_DISMISSED_NOTIFICATION_ID = 2 class ServiceStoppedWorker( context: Context, params: WorkerParameters @@ -25,9 +31,16 @@ class ServiceStoppedWorker( val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val notificationIntent = Intent(applicationContext, MainActivity::class.java) + val notificationIntent = Intent(applicationContext, MainActivity::class.java).apply { + putExtra(FG_NOTIFICATION_DISMISSED, true) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } val pendingIntent = PendingIntent.getActivity( - applicationContext, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE + applicationContext, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) @@ -36,8 +49,33 @@ class ServiceStoppedWorker( .setContentText("Tap to understand why its important to resume it.") .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) .build() - notificationManager.notify(2, notification) + notificationManager.notify(FG_NOTIFICATION_DISMISSED_NOTIFICATION_ID, notification) } } + +@Composable +fun FGDismissedStoppedDialog( + onDismiss: () -> Unit, + onRestartService: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Service Stopped") }, + text = { Text("The location service has stopped. Would you like to restart it?") }, + confirmButton = { + TextButton(onClick = onRestartService) { + Text("Restart Service") + } + }, + dismissButton = { + TextButton(onClick = { + onDismiss() + }) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/me/itissid/privyloci/ui/AdaptiveIcon.kt b/app/src/main/java/me/itissid/privyloci/ui/AdaptiveIcon.kt index 3319936..422bd02 100644 --- a/app/src/main/java/me/itissid/privyloci/ui/AdaptiveIcon.kt +++ b/app/src/main/java/me/itissid/privyloci/ui/AdaptiveIcon.kt @@ -27,14 +27,14 @@ fun AdaptiveIcon(locationPermissionGranted: Boolean) { if (isSystemInDarkTheme()) darkScheme.error else lightScheme.error } val scale by if (!locationPermissionGranted) { - val infiniteTransition = rememberInfiniteTransition() + val infiniteTransition = rememberInfiniteTransition(label = "Infinite Transition") infiniteTransition.animateFloat( initialValue = 0.8f, targetValue = 1.2f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 700, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse - ) + ), label = "animation" ) } else { // When condition is false, simply return a static scale of 1f diff --git a/app/src/main/java/me/itissid/privyloci/ui/LocationPermissionRationaleDialogue.kt b/app/src/main/java/me/itissid/privyloci/ui/LocationPermissionRationaleDialogue.kt index c2b6983..6d69a52 100644 --- a/app/src/main/java/me/itissid/privyloci/ui/LocationPermissionRationaleDialogue.kt +++ b/app/src/main/java/me/itissid/privyloci/ui/LocationPermissionRationaleDialogue.kt @@ -22,23 +22,29 @@ import androidx.compose.ui.unit.dp import me.itissid.privyloci.ui.theme.PrivyLociTheme @Composable -fun LocationPermissionRationaleDialogue(onDismiss: () -> Unit, onConfirm: () -> Unit) { +fun LocationPermissionRationaleDialogue( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + message: String, + proceedText: String = "Proceed", + dismissText: String = "Cancel" +) { AlertDialog( onDismissRequest = { onDismiss() }, confirmButton = { TextButton(onClick = { onConfirm() }) { - Text("Proceed") + Text(proceedText) } }, dismissButton = { TextButton(onClick = { onDismiss() }) { - Text("Cancel") + Text(dismissText) } }, text = { - Text("Location permissions have not been granted. Please enable location permissions for the app to function properly.") + Text(message) } - ) // TODO(Sid): Else show that location permissions are granted + ) } diff --git a/app/src/main/java/me/itissid/privyloci/ui/MainScreenPreview.kt b/app/src/main/java/me/itissid/privyloci/ui/MainScreenPreview.kt index 1434611..a11412e 100644 --- a/app/src/main/java/me/itissid/privyloci/ui/MainScreenPreview.kt +++ b/app/src/main/java/me/itissid/privyloci/ui/MainScreenPreview.kt @@ -1,7 +1,17 @@ package me.itissid.privyloci.ui import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import me.itissid.privyloci.MainScreen import me.itissid.privyloci.data.DataProvider @@ -43,4 +53,3 @@ fun PlacesAndAssetScreenPreview() { PlacesAndAssetsScreen(placesList + assetsList) } } - diff --git a/app/src/main/java/me/itissid/privyloci/util/Logger.kt b/app/src/main/java/me/itissid/privyloci/util/Logger.kt index 9909e8b..9927727 100644 --- a/app/src/main/java/me/itissid/privyloci/util/Logger.kt +++ b/app/src/main/java/me/itissid/privyloci/util/Logger.kt @@ -5,6 +5,9 @@ import android.util.Log object Logger { private const val TAG = "PrivyLociLogger" + fun v(className: String, message: String) { + Log.v(TAG, "[$className] $message") + } fun d(className: String, message: String) { Log.d(TAG, "[$className] $message") } diff --git a/app/src/main/java/me/itissid/privyloci/viewmodels/MainViewModel.kt b/app/src/main/java/me/itissid/privyloci/viewmodels/MainViewModel.kt new file mode 100644 index 0000000..809c541 --- /dev/null +++ b/app/src/main/java/me/itissid/privyloci/viewmodels/MainViewModel.kt @@ -0,0 +1,162 @@ +package me.itissid.privyloci.viewmodels + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import me.itissid.privyloci.UserPreferences +import me.itissid.privyloci.service.PrivyForegroundService +import me.itissid.privyloci.util.Logger +import javax.inject.Inject + +enum class ForeGroundServiceRationaleState { + /** Dialogue rationale states for controlling the foreground service + * */ + FG_PERMISSION_RATIONALE_DISMISSED, + PERSISTENT_NOTIFICATION_DISMISSED, + REACTIVATE_FG_RATIONALE_DISMISSED, +} + +enum class RationaleState { + + /** + Dialogue rationale states For the foreground permissions. + * */ + LOCATION_PERMISSION_RATIONALE_SHOULD_BE_SHOWN, + MULTIPLE_PERMISSIONS_SHOULD_BE_LAUNCHED, + VISIT_SETTINGS, + PAUSE_LOCATION_COLLECTION, + RESUME_LOCATION_COLLECTION, +} + +data class ForegroundPermissionRationaleState( + val reason: RationaleState, + val rationaleText: String = "", + val proceedButtonText: String = "Proceed", + val dismissButtonText: String = "Cancel" +) + + +@HiltViewModel +class MainViewModel @Inject constructor( + application: Application, + private val userPreferences: UserPreferences +) : + AndroidViewModel(application) { + + private val _isServiceRunning = MutableLiveData() + val isServiceRunning: LiveData get() = _isServiceRunning + + // Expose preferences as StateFlow public variables. + val wasFGPermissionRationaleDismissed: StateFlow = + userPreferences.wasFGPerrmissionRationaleDismissed + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false + ) + + val wasFGPersistentNotificationDismissed: StateFlow = + userPreferences.wasFGPersistentNotificationDismissed + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false + ) + + val wasReactivateFGRationaleDismissed: StateFlow = + userPreferences.wasReactivateFGRationaleDismissed + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false + ) + + val userVisitedPermissionLauncher: StateFlow = + userPreferences.userVisitedPermissionLauncher + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false + ) + val userPausedLocationCollection: StateFlow = + userPreferences.userPausedLocationCollection + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false + ) + + private val serviceStatusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + PrivyForegroundService.ACTION_SERVICE_STARTED -> _isServiceRunning.value = true + PrivyForegroundService.ACTION_SERVICE_STOPPED -> _isServiceRunning.value = false + } + } + } + + init { + // Register the receiver + val filter = IntentFilter().apply { + addAction(PrivyForegroundService.ACTION_SERVICE_STARTED) + addAction(PrivyForegroundService.ACTION_SERVICE_STOPPED) + } + LocalBroadcastManager.getInstance(getApplication()) + .registerReceiver(serviceStatusReceiver, filter) + } + + fun setFGPermissionRationaleDismissed(dismissed: Boolean) { + viewModelScope.launch { + userPreferences.setFGPermissionRationaleDismissed(dismissed) + } + } + + fun setFGPersistentNotificationDismissed(dismissed: Boolean) { + viewModelScope.launch { + userPreferences.setFGPersistentNotificationDismissed(dismissed) + } + } + + fun setReactivateFGRationaleDismissed(dismissed: Boolean) { + viewModelScope.launch { + userPreferences.setReactivateFGRationaleDismissed(dismissed) + } + } + + fun setUserPausedLocationCollection(paused: Boolean) { + viewModelScope.launch { + userPreferences.setUserPausedLocationCollection(paused) + } + } + + fun setUserVisitedPermissionLauncherPreference(dismissed: Boolean) { + + viewModelScope.launch { + try { + userPreferences.setUserVisitedPermissionLauncher(dismissed) + } catch (e: Exception) { + Logger.e("MainViewModel", "Error setting UserVisitedPermissionLauncher", e) + } + } + } + + override fun onCleared() { + super.onCleared() + // Unregister the receiver + LocalBroadcastManager.getInstance(getApplication()) + .unregisterReceiver(serviceStatusReceiver) + } +}