Skip to content

Commit

Permalink
Redone location permission settings with user intervention. This arch…
Browse files Browse the repository at this point in the history
…itecture will help in FGService control as well
  • Loading branch information
itissid committed Dec 2, 2024
1 parent da843a3 commit 98d46d7
Show file tree
Hide file tree
Showing 15 changed files with 609 additions and 104 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ concatenated_code.txt
docs/FG Evolve.rtf
docs/LocationPermissionEvolve.md

app/src/main/java/me/itissid/privyloci/ui/Scratch.kt

3 changes: 3 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
-assumenosideeffects class android.util.Log {
public static int d(...);
}
-assumenosideeffects class android.util.Log {
public static int v(...);
}
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

<activity
android:name="me.itissid.privyloci.MainActivity"
android:launchMode="singleTop"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
Expand Down
309 changes: 229 additions & 80 deletions app/src/main/java/me/itissid/privyloci/MainActivity.kt

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/src/main/java/me/itissid/privyloci/Permissions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package me.itissid.privyloci

class Permissions
92 changes: 92 additions & 0 deletions app/src/main/java/me/itissid/privyloci/UserPreferences.kt
Original file line number Diff line number Diff line change
@@ -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<Preferences> = 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.

}
}
29 changes: 29 additions & 0 deletions app/src/main/java/me/itissid/privyloci/data/DataStoreModule.kt
Original file line number Diff line number Diff line change
@@ -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<Preferences> =
// PreferenceDataStoreFactory.create(
// produceFIle = { appContext.preferencesDataStoreFile("user_preferences") }
// )
//
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -81,7 +82,8 @@ class PrivyForegroundService : Service() {
e
)
} finally {
ServiceStateHolder.isServiceRunning = false
val intent = Intent(ACTION_SERVICE_STOPPED)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
}

Expand Down Expand Up @@ -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<ServiceStoppedWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ 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
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
Expand All @@ -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)
Expand All @@ -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")
}
}
)
}
4 changes: 2 additions & 2 deletions app/src/main/java/me/itissid/privyloci/ui/AdaptiveIcon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}


Expand Down
Loading

0 comments on commit 98d46d7

Please sign in to comment.