Skip to content

Commit 7c85013

Browse files
committed
Remove FOREGROUND_SERVICE permission
This means we need to handle the notifications on our own. So I also had to implement all of the notification handling
1 parent f197597 commit 7c85013

16 files changed

Lines changed: 233 additions & 49 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@
99
<uses-permission android:name="android.permission.CAMERA"/>
1010
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
1111
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
12+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
1213
<uses-permission android:name="android.permission.VIBRATE"/>
13-
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
14-
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" android:minSdkVersion="34"/>
1514

1615
<application
1716
android:name=".SnapSafeApplication"
@@ -69,6 +68,10 @@
6968
<service
7069
android:name="androidx.work.impl.foreground.SystemForegroundService"
7170
android:foregroundServiceType="dataSync"/>
71+
72+
<receiver
73+
android:name=".import.ImportCancelReceiver"
74+
android:exported="false"/>
7275
</application>
7376

7477
</manifest>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.darkrockstudios.app.securecamera.import
2+
3+
import android.app.NotificationManager
4+
import android.content.BroadcastReceiver
5+
import android.content.Context
6+
import android.content.Intent
7+
import androidx.work.WorkManager
8+
import com.darkrockstudios.app.securecamera.import.ImportWorker.Companion.NOTIFICATION_ID
9+
import timber.log.Timber
10+
11+
/**
12+
* BroadcastReceiver to handle cancellation of the ImportWorker
13+
*/
14+
class ImportCancelReceiver : BroadcastReceiver() {
15+
companion object {
16+
const val ACTION_CANCEL_IMPORT = "com.darkrockstudios.app.securecamera.import.CANCEL_IMPORT"
17+
const val EXTRA_WORKER_ID = "EXTRA_WORKER_ID"
18+
}
19+
20+
override fun onReceive(context: Context, intent: Intent) {
21+
if (intent.action == ACTION_CANCEL_IMPORT) {
22+
val workerId = intent.getStringExtra(EXTRA_WORKER_ID)
23+
if (workerId != null) {
24+
Timber.d("Cancelling import worker: $workerId")
25+
WorkManager.getInstance(context).cancelWorkById(workerId.toUUID())
26+
27+
// Dismiss notification when worker is cancelled
28+
val notificationManager =
29+
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
30+
notificationManager.cancel(NOTIFICATION_ID)
31+
} else {
32+
Timber.e("No worker ID provided in cancel intent")
33+
}
34+
}
35+
}
36+
37+
private fun String.toUUID() = java.util.UUID.fromString(this)
38+
}

app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosContent.kt

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package com.darkrockstudios.app.securecamera.import
22

3+
import android.Manifest
4+
import android.content.pm.PackageManager
35
import android.graphics.Bitmap
46
import android.graphics.ImageDecoder
57
import android.net.Uri
8+
import android.os.Build
69
import androidx.activity.compose.BackHandler
10+
import androidx.activity.compose.rememberLauncherForActivityResult
11+
import androidx.activity.result.contract.ActivityResultContracts
712
import androidx.compose.foundation.Image
813
import androidx.compose.foundation.layout.*
914
import androidx.compose.material3.*
@@ -14,6 +19,7 @@ import androidx.compose.ui.graphics.asImageBitmap
1419
import androidx.compose.ui.platform.LocalContext
1520
import androidx.compose.ui.res.stringResource
1621
import androidx.compose.ui.unit.dp
22+
import androidx.core.content.ContextCompat
1723
import androidx.navigation.NavHostController
1824
import com.darkrockstudios.app.securecamera.R
1925
import com.darkrockstudios.app.securecamera.navigation.AppDestinations
@@ -29,40 +35,18 @@ fun ImportPhotosContent(
2935
val context = LocalContext.current
3036
val uiState by viewModel.uiState.collectAsState()
3137

32-
// State for cancel confirmation dialog
33-
val showCancelDialog = remember { mutableStateOf(false) }
38+
var showCancelDialog by remember { mutableStateOf(false) }
3439

35-
// Handle system back button
3640
BackHandler(enabled = !uiState.complete) {
37-
showCancelDialog.value = true
41+
showCancelDialog = true
3842
}
3943

40-
// Cancel confirmation dialog
41-
if (showCancelDialog.value) {
42-
AlertDialog(
43-
onDismissRequest = { showCancelDialog.value = false },
44-
title = { Text(stringResource(id = R.string.discard_changes_dialog_title)) },
45-
text = { Text(stringResource(id = R.string.discard_changes_dialog_message)) },
46-
confirmButton = {
47-
TextButton(
48-
onClick = {
49-
showCancelDialog.value = false
50-
navController.navigate(AppDestinations.GALLERY_ROUTE) {
51-
popUpTo(0) { inclusive = true }
52-
}
53-
}
54-
) {
55-
Text(stringResource(id = R.string.discard_button))
56-
}
57-
},
58-
dismissButton = {
59-
TextButton(
60-
onClick = { showCancelDialog.value = false }
61-
) {
62-
Text(stringResource(id = R.string.cancel_button))
63-
}
64-
}
65-
)
44+
NotificationPermissionRationale()
45+
46+
if (showCancelDialog) {
47+
CancelImportDialog(navController) {
48+
showCancelDialog = false
49+
}
6650
}
6751

6852
var currentBitmap by remember { mutableStateOf<Bitmap?>(null) }
@@ -179,3 +163,89 @@ fun ImportPhotosContent(
179163
}
180164
}
181165
}
166+
167+
@Composable
168+
private fun CancelImportDialog(navController: NavHostController, dismiss: () -> Unit) {
169+
val viewModel: ImportPhotosViewModel = koinViewModel()
170+
171+
AlertDialog(
172+
onDismissRequest = { dismiss() },
173+
title = { Text(stringResource(id = R.string.discard_changes_dialog_title)) },
174+
text = { Text(stringResource(id = R.string.discard_changes_dialog_message)) },
175+
confirmButton = {
176+
TextButton(
177+
onClick = {
178+
viewModel.cancelImport()
179+
dismiss()
180+
navController.navigate(AppDestinations.GALLERY_ROUTE) {
181+
popUpTo(0) { inclusive = true }
182+
}
183+
}
184+
) {
185+
Text(stringResource(id = R.string.discard_button))
186+
}
187+
},
188+
dismissButton = {
189+
TextButton(
190+
onClick = { dismiss() }
191+
) {
192+
Text(stringResource(id = R.string.cancel_button))
193+
}
194+
}
195+
)
196+
}
197+
198+
@Composable
199+
private fun NotificationPermissionRationale() {
200+
val context = LocalContext.current
201+
202+
val showNotificationPermissionDialog = remember { mutableStateOf(false) }
203+
204+
val notificationPermissionLauncher = rememberLauncherForActivityResult(
205+
contract = ActivityResultContracts.RequestPermission()
206+
) { _ ->
207+
// Noop
208+
}
209+
210+
// Check if we need to request notification permission (API 33+)
211+
LaunchedEffect(Unit) {
212+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
213+
val notificationPermission = Manifest.permission.POST_NOTIFICATIONS
214+
if (ContextCompat.checkSelfPermission(
215+
context,
216+
notificationPermission
217+
) != PackageManager.PERMISSION_GRANTED
218+
) {
219+
showNotificationPermissionDialog.value = true
220+
}
221+
}
222+
}
223+
224+
// Notification permission dialog (API 33+)
225+
if (showNotificationPermissionDialog.value) {
226+
AlertDialog(
227+
onDismissRequest = { showNotificationPermissionDialog.value = false },
228+
title = { Text(stringResource(id = R.string.notification_permission_dialog_title)) },
229+
text = { Text(stringResource(id = R.string.notification_permission_dialog_message)) },
230+
confirmButton = {
231+
TextButton(
232+
onClick = {
233+
showNotificationPermissionDialog.value = false
234+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
235+
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
236+
}
237+
}
238+
) {
239+
Text(stringResource(id = R.string.notification_permission_button))
240+
}
241+
},
242+
dismissButton = {
243+
TextButton(
244+
onClick = { showNotificationPermissionDialog.value = false }
245+
) {
246+
Text(stringResource(id = R.string.cancel_button))
247+
}
248+
}
249+
)
250+
}
251+
}

app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosViewModel.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class ImportPhotosViewModel(
1616
) : BaseViewModel<ImportPhotosState>() {
1717

1818
val hasher: Hasher = CryptographyProvider.Default.get(SHA512).hasher()
19+
private var currentImportWorkName: String? = null
1920

2021
companion object {
2122
private const val IMPORT_WORK_NAME = "photo_import_work_"
@@ -50,9 +51,11 @@ class ImportPhotosViewModel(
5051
.build()
5152

5253
val uniqueWorkId = hasher.hashBlocking(photos.joinToString { it.toString() }.toByteArray())
54+
val workerName = IMPORT_WORK_NAME + uniqueWorkId
55+
currentImportWorkName = workerName
5356

5457
workManager.enqueueUniqueWork(
55-
IMPORT_WORK_NAME + uniqueWorkId,
58+
workerName,
5659
ExistingWorkPolicy.KEEP,
5760
importWorkRequest
5861
)
@@ -120,6 +123,13 @@ class ImportPhotosViewModel(
120123
}
121124
}
122125
}
126+
127+
fun cancelImport() {
128+
currentImportWorkName?.let { workName ->
129+
Timber.d("Cancelling import work: $workName")
130+
workManager.cancelUniqueWork(workName)
131+
}
132+
}
123133
}
124134

125135
data class ImportPhotosState(

app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportWorker.kt

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ package com.darkrockstudios.app.securecamera.import
22

33
import android.app.NotificationChannel
44
import android.app.NotificationManager
5+
import android.app.PendingIntent
56
import android.content.Context
6-
import android.content.pm.ServiceInfo
7+
import android.content.Intent
78
import android.graphics.BitmapFactory
89
import android.net.Uri
910
import androidx.core.app.NotificationCompat
1011
import androidx.core.net.toUri
1112
import androidx.work.CoroutineWorker
12-
import androidx.work.ForegroundInfo
1313
import androidx.work.WorkerParameters
1414
import androidx.work.workDataOf
1515
import com.ashampoo.kim.Kim
@@ -45,7 +45,7 @@ class ImportWorker(
4545
const val KEY_CURRENT_PHOTO_URI = "KEY_CURRENT_PHOTO_URI"
4646

4747
private const val NOTIFICATION_CHANNEL_ID = "import_photos_channel"
48-
private const val NOTIFICATION_ID = 1
48+
const val NOTIFICATION_ID = 1
4949
}
5050

5151
@OptIn(ExperimentalTime::class)
@@ -57,14 +57,12 @@ class ImportWorker(
5757
return Result.success()
5858
}
5959

60-
// Set up as foreground service with notification
61-
setForeground(createForegroundInfo(0, photoUris.size))
60+
showProgressNotification(0, photoUris.size)
6261

6362
var successfulPhotos = 0
6463
var failedPhotos = 0
6564

6665
photoUris.forEachIndexed { index, photoUri ->
67-
// Update progress data
6866
val progressData = workDataOf(
6967
KEY_TOTAL_PHOTOS to photoUris.size,
7068
KEY_REMAINING_PHOTOS to (photoUris.size - index - 1),
@@ -74,7 +72,7 @@ class ImportWorker(
7472
)
7573
setProgress(progressData)
7674

77-
setForeground(createForegroundInfo(index + 1, photoUris.size))
75+
showProgressNotification(index + 1, photoUris.size)
7876

7977
val jpgBytes = readPhotoBytes(photoUri)
8078
if (jpgBytes != null) {
@@ -115,7 +113,8 @@ class ImportWorker(
115113
}
116114
}
117115

118-
// Return success with the final count of successful and failed imports
116+
dismissNotification()
117+
119118
return Result.success(
120119
workDataOf(
121120
KEY_SUCCESSFUL_PHOTOS to successfulPhotos,
@@ -133,26 +132,44 @@ class ImportWorker(
133132
}
134133
}
135134

136-
private fun createForegroundInfo(progress: Int, total: Int): ForegroundInfo {
135+
private fun showProgressNotification(progress: Int, total: Int) {
137136
createNotificationChannel()
138137

139138
val title = applicationContext.getString(R.string.import_worker_notification_title)
140139
val contentText = applicationContext.getString(R.string.import_worker_notification_content, progress, total)
141140

141+
// Create cancel intent
142+
val cancelIntent = Intent(applicationContext, ImportCancelReceiver::class.java).apply {
143+
action = ImportCancelReceiver.ACTION_CANCEL_IMPORT
144+
putExtra(ImportCancelReceiver.EXTRA_WORKER_ID, id.toString())
145+
}
146+
147+
// Create pending intent for cancel action
148+
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
149+
val cancelPendingIntent = PendingIntent.getBroadcast(
150+
applicationContext,
151+
0,
152+
cancelIntent,
153+
flags
154+
)
155+
142156
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
143157
.setSmallIcon(R.drawable.ic_launcher_foreground)
144158
.setContentTitle(title)
145159
.setContentText(contentText)
146160
.setSmallIcon(android.R.drawable.ic_menu_gallery)
147161
.setOngoing(true)
148162
.setProgress(total, progress, false)
163+
.addAction(
164+
android.R.drawable.ic_menu_close_clear_cancel,
165+
applicationContext.getString(R.string.cancel_button),
166+
cancelPendingIntent
167+
)
149168
.build()
150169

151-
return ForegroundInfo(
152-
NOTIFICATION_ID,
153-
notification,
154-
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
155-
)
170+
val notificationManager =
171+
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
172+
notificationManager.notify(NOTIFICATION_ID, notification)
156173
}
157174

158175
private fun createNotificationChannel() {
@@ -167,4 +184,10 @@ class ImportWorker(
167184
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
168185
notificationManager.createNotificationChannel(channel)
169186
}
187+
188+
private fun dismissNotification() {
189+
val notificationManager =
190+
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
191+
notificationManager.cancel(NOTIFICATION_ID)
192+
}
170193
}

app/src/main/res/values-de/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,9 @@
254254
meisten Benutzer im Allgemeinen nicht erforderlich, bietet jedoch eine optionale Sicherheitsebene, wenn Ihr
255255
Bedrohungsmodell dies verlangt.
256256
</string>
257+
<string name="notification_permission_dialog_title">Benachrichtigungen erlauben</string>
258+
<string name="notification_permission_button">Zulassen</string>
259+
<string name="notification_permission_dialog_message">SnapSafe benötigt die Benachrichtigungsberechtigung, um den
260+
Fortschritt beim Importieren von Fotos im Hintergrund anzuzeigen.
261+
</string>
257262
</resources>

0 commit comments

Comments
 (0)