This commit is contained in:
Minecon724 2025-04-26 15:47:21 +02:00
commit 91903e5282
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
7 changed files with 232 additions and 56 deletions

View file

@ -7,6 +7,10 @@
<package android:name="net.kdt.pojavlaunch.debug" />
</queries>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application
android:name=".PojavBackupApplication"
android:allowBackup="true"
@ -39,6 +43,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
@ -50,6 +55,10 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View file

@ -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<Throwable>()
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)
}
}

View file

@ -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<HomeUiState> = _uiState.asStateFlow()
init {
val notificationManager =
appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
NotificationChannels.register(notificationManager)
}
fun load(
onSetupNeeded: () -> Unit
) {

View file

@ -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)
}
}
}

View file

@ -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")
}
}
}

View file

@ -1,5 +1,6 @@
package eu.m724.pojavbackup.setup
data class SetupUiState(
val storagePermissionGranted: Boolean = false
val storagePermissionGranted: Boolean = false,
val notificationPermissionGranted: Boolean = false
)

View file

@ -32,6 +32,24 @@ class SetupViewModel @Inject constructor(
private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _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) {