From 91903e528273a7e969e8c79fa9d342c0f499dbd4 Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Sat, 26 Apr 2025 15:47:21 +0200 Subject: [PATCH] Update --- app/src/main/AndroidManifest.xml | 11 +- .../pojavbackup/core/backup/BackupWorker.kt | 105 +++++++++++++--- .../eu/m724/pojavbackup/home/HomeViewModel.kt | 9 ++ .../notification/NotificationChannels.kt | 24 ++++ .../m724/pojavbackup/setup/SetupActivity.kt | 118 ++++++++++++------ .../eu/m724/pojavbackup/setup/SetupUiState.kt | 3 +- .../m724/pojavbackup/setup/SetupViewModel.kt | 18 +++ 7 files changed, 232 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/eu/m724/pojavbackup/notification/NotificationChannels.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b773e42..2b0e5ee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,10 @@ + + + + + - + + \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt index da7f06f..965acf1 100644 --- a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt @@ -1,21 +1,29 @@ package eu.m724.pojavbackup.core.backup +import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import eu.m724.pojavbackup.R import eu.m724.pojavbackup.core.backup.Backup.BackupStatus import eu.m724.pojavbackup.core.data.GameDataRepository import eu.m724.pojavbackup.core.datastore.SettingsRepository +import eu.m724.pojavbackup.notification.NotificationChannels +import kotlinx.coroutines.delay import org.apache.commons.compress.compressors.CompressorStreamFactory import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.UUID +import kotlin.random.Random @HiltWorker class BackupWorker @AssistedInject constructor( @@ -25,30 +33,45 @@ class BackupWorker @AssistedInject constructor( private val backupRepository: BackupRepository, private val gameDataRepository: GameDataRepository ) : CoroutineWorker(appContext, workerParams) { + private val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as + NotificationManager + + private val notificationId: Int = + Random.nextInt() + + private val errors = mutableListOf() + + private lateinit var backup: Backup + override suspend fun doWork(): Result { - setProgress(statusData(UUID(0, 0), "Initializing")) + try { + backup = backupRepository.createBackup() - val backup = backupRepository.createBackup() + updateStatus("Initializing") - val settings = settingsRepository.getSettings() - val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder) + delay(10000) - // TODO multiple destinations support and remove those !! - val backupUri = settings.destinationsList.first().uri.toUri() - val backupDirectory = DocumentFile.fromTreeUri(applicationContext, backupUri)!! - .createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backup.id + ")")!! + val settings = settingsRepository.getSettings() + val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder) + + // TODO multiple destinations support and remove those !! + val backupUri = settings.destinationsList.first().uri.toUri() + val backupDirectory = DocumentFile.fromTreeUri(applicationContext, backupUri)!! + .createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backup.id + ")")!! - return try { worldIds.forEach { - setProgress(statusData(backup.id, "$it: now backing up")) + updateStatus("Backing up world $it") val world = runCatching { gameDataRepository.getWorld(it) } if (world.isFailure) { - Exception("Error backing up world $it", world.exceptionOrNull()).printStackTrace() - setProgress(statusData(backup.id, "$it: error")) + val e = Exception("Error backing up world $it", world.exceptionOrNull()) + errors.add(e) + + e.printStackTrace() return@forEach } @@ -65,27 +88,71 @@ class BackupWorker @AssistedInject constructor( ) } } + } catch (e: Exception) { + val e = Exception("Error backing up", e) + errors.add(e) + e.printStackTrace() + } + + if (errors.isEmpty()) { backupRepository.completeBackup(backup, BackupStatus.SUCCESS) - Result.success(statusData(backup.id, "Success")) - } catch (e: Exception) { - Exception("Error backing up", e).printStackTrace() - + return Result.success(statusData("Backup completed successfully")) + } else { backupRepository.completeBackup(backup, BackupStatus.FAILURE) - Result.failure(statusData(backup.id, "Error: $e")) + // TODO notify and tell error + + return Result.failure(statusData("Backup failed")) } } - fun statusData(uuid: UUID, status: String): Data { + fun statusData(status: String): Data { return Data.Builder() - .putUuid("uuid", uuid) + .putUuid("uuid", backup.id) .putString("status", status) .build() } + suspend fun updateStatus(status: String) { + setForeground(createForegroundInfo(status)) + + val data = statusData(status) + + setProgress(data) + } + + fun finish(status: String, success: Boolean) { + val data = statusData(status) + + if (success) { + Result.success(data) + } else { + Result.failure(data) + } + } + + fun Data.Builder.putUuid(key: String, value: UUID): Data.Builder { return this.putLongArray(key, longArrayOf(value.mostSignificantBits, value.leastSignificantBits)) } + + // Creates an instance of ForegroundInfo which can be used to update the + // ongoing notification. + private fun createForegroundInfo(progress: String): ForegroundInfo { + val title = "Backup in progress" + + val notification = NotificationCompat.Builder(applicationContext, NotificationChannels.BACKUP_PROGRESS) + .setContentTitle(title) + .setTicker(title) + .setContentText(progress) + .setSmallIcon(R.drawable.baseline_history_24) // TODO icon + .setOngoing(true) + .build() + + // TODO add action to pause and cancel + + return ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt index 99e0b76..38b2afc 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt @@ -1,5 +1,6 @@ package eu.m724.pojavbackup.home +import android.app.NotificationManager import android.content.Context import android.net.Uri import android.util.Log @@ -10,6 +11,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import eu.m724.pojavbackup.core.data.GameDataRepository import eu.m724.pojavbackup.core.datastore.SettingsRepository +import eu.m724.pojavbackup.notification.NotificationChannels import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,6 +30,13 @@ class HomeViewModel @Inject constructor( private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + val notificationManager = + appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + NotificationChannels.register(notificationManager) + } + fun load( onSetupNeeded: () -> Unit ) { diff --git a/app/src/main/java/eu/m724/pojavbackup/notification/NotificationChannels.kt b/app/src/main/java/eu/m724/pojavbackup/notification/NotificationChannels.kt new file mode 100644 index 0000000..517743b --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/notification/NotificationChannels.kt @@ -0,0 +1,24 @@ +package eu.m724.pojavbackup.notification + +import android.app.NotificationChannel +import android.app.NotificationManager + +object NotificationChannels { + const val BACKUP_PROGRESS = "BACKUP_PROGRESS" + const val BACKUP_ERROR = "BACKUP_ERROR" + + fun register(notificationManager: NotificationManager) { + arrayOf( + NotificationChannel(BACKUP_PROGRESS, "Backup progress", NotificationManager.IMPORTANCE_LOW).apply { + description = "Appears when a backup is in progress and gives you status" + }, + + NotificationChannel(BACKUP_ERROR, "Backup error", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "Appears when a backup failed" + } + ).forEach { + notificationManager.createNotificationChannel(it) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt index f50b8d8..2e23632 100644 --- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt +++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt @@ -51,7 +51,22 @@ class SetupActivity : ComponentActivity() { } } + private val notificationGrant = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + onComplete() + } else { + // TODO instead red text? + Toast.makeText( + applicationContext, + "This is not a PojavLauncher directory.", + Toast.LENGTH_SHORT + ).show() + } + } + fun onComplete() { + if (!viewModel.checkIfCanProceed()) return + startActivity(Intent(applicationContext, HomeActivity::class.java)) finishActivity(0) } @@ -61,6 +76,11 @@ class SetupActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + val storagePermissionGranted = intent.getBooleanExtra("storagePermissionGranted", false) + val notificationPermissionGranted = intent.getBooleanExtra("notificationPermissionGranted", false) + + viewModel.init(storagePermissionGranted, notificationPermissionGranted) + // println("Found pojav launchers: ${packages.joinToString(", ")}") val defaultUri = "content://net.kdt.pojavlaunch.scoped.gamefolder.debug/tree/%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fnet.kdt.pojavlaunch.debug%2Ffiles".toUri() @@ -71,7 +91,10 @@ class SetupActivity : ComponentActivity() { SetupScreen( modifier = Modifier.padding(innerPadding), viewModel = viewModel, - onGrantClick = { + onStoragePermissionGrantClick = { + openDocumentTree.launch(defaultUri) + }, + onNotificationPermissionGrantClick = { openDocumentTree.launch(defaultUri) } ) @@ -85,7 +108,8 @@ class SetupActivity : ComponentActivity() { fun SetupScreen( modifier: Modifier = Modifier, viewModel: SetupViewModel, - onGrantClick: () -> Unit + onStoragePermissionGrantClick: () -> Unit, + onNotificationPermissionGrantClick: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -100,41 +124,65 @@ fun SetupScreen( ) - Card( - elevation = - if (uiState.storagePermissionGranted) { - CardDefaults.cardElevation() - } else { - CardDefaults.elevatedCardElevation() - } + GrantCard( + title = "Storage permission", + description = "It's needed to access your saves etc.", + granted = uiState.storagePermissionGranted, + onClick = onStoragePermissionGrantClick + ) + + GrantCard( + title = "Notification permission", + description = "It's needed to notify you about backup status.", + granted = uiState.storagePermissionGranted, + onClick = onNotificationPermissionGrantClick + ) + } +} + +@Composable +fun GrantCard( + modifier: Modifier = Modifier, + title: String, + description: String, + granted: Boolean, + onClick: () -> Unit +) { + Card( + modifier = modifier, + elevation = + if (granted) { + CardDefaults.cardElevation() + } else { + CardDefaults.elevatedCardElevation() + } + ) { + Column( + modifier = Modifier.width(300.dp).padding( + horizontal = 20.dp, + vertical = 10.dp + ) ) { - Column( - modifier = Modifier.width(300.dp).padding( - horizontal = 20.dp, - vertical = 10.dp - ) + Text( + text = title, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = description + ) + + Button( + onClick = onClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = !granted ) { - Text( - text = "Storage permission", - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - - Text( - text = "It's needed to access your saves etc." - ) - - Button( - onClick = onGrantClick, - modifier = Modifier.align(Alignment.CenterHorizontally), - enabled = !uiState.storagePermissionGranted - ) { - if (uiState.storagePermissionGranted) { - Text("Already granted") - } else { - Text("Grant") - } + if (granted) { + Text("Already granted") + } else { + Text("Grant") } } } diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupUiState.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupUiState.kt index 2804b3a..00b36e2 100644 --- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupUiState.kt +++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupUiState.kt @@ -1,5 +1,6 @@ package eu.m724.pojavbackup.setup data class SetupUiState( - val storagePermissionGranted: Boolean = false + val storagePermissionGranted: Boolean = false, + val notificationPermissionGranted: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt index 56fd903..c38d32e 100644 --- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt +++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt @@ -32,6 +32,24 @@ class SetupViewModel @Inject constructor( private val _uiState = MutableStateFlow(SetupUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun init( + storagePermissionGranted: Boolean, + notificationPermissionGranted: Boolean + ) { + _uiState.update { + it.copy( + storagePermissionGranted = storagePermissionGranted, + notificationPermissionGranted = notificationPermissionGranted + ) + } + } + + fun checkIfCanProceed(): Boolean { + val state = _uiState.value + + return state.storagePermissionGranted && state.notificationPermissionGranted + } + // TODO we could make the check call separate and not pass context here fun onOpenDocumentTree(context: Context, uri: Uri?, result: (Boolean) -> Unit) { if (uri != null) {