This commit is contained in:
Minecon724 2025-04-26 15:47:21 +02:00
commit 91903e5282
Signed by untrusted user who does not match committer: m724
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" /> <package android:name="net.kdt.pojavlaunch.debug" />
</queries> </queries>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application <application
android:name=".PojavBackupApplication" android:name=".PojavBackupApplication"
android:allowBackup="true" android:allowBackup="true"
@ -39,6 +43,7 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider <provider
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"
@ -50,6 +55,10 @@
android:value="androidx.startup" android:value="androidx.startup"
tools:node="remove" /> tools:node="remove" />
</provider> </provider>
</application>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest> </manifest>

View file

@ -1,21 +1,29 @@
package eu.m724.pojavbackup.core.backup package eu.m724.pojavbackup.core.backup
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.core.backup.Backup.BackupStatus import eu.m724.pojavbackup.core.backup.Backup.BackupStatus
import eu.m724.pojavbackup.core.data.GameDataRepository import eu.m724.pojavbackup.core.data.GameDataRepository
import eu.m724.pojavbackup.core.datastore.SettingsRepository 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 org.apache.commons.compress.compressors.CompressorStreamFactory
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
import kotlin.random.Random
@HiltWorker @HiltWorker
class BackupWorker @AssistedInject constructor( class BackupWorker @AssistedInject constructor(
@ -25,30 +33,45 @@ class BackupWorker @AssistedInject constructor(
private val backupRepository: BackupRepository, private val backupRepository: BackupRepository,
private val gameDataRepository: GameDataRepository private val gameDataRepository: GameDataRepository
) : CoroutineWorker(appContext, workerParams) { ) : 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 { 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() delay(10000)
val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder)
// TODO multiple destinations support and remove those !! val settings = settingsRepository.getSettings()
val backupUri = settings.destinationsList.first().uri.toUri() val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder)
val backupDirectory = DocumentFile.fromTreeUri(applicationContext, backupUri)!!
.createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backup.id + ")")!! // 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 { worldIds.forEach {
setProgress(statusData(backup.id, "$it: now backing up")) updateStatus("Backing up world $it")
val world = runCatching { val world = runCatching {
gameDataRepository.getWorld(it) gameDataRepository.getWorld(it)
} }
if (world.isFailure) { if (world.isFailure) {
Exception("Error backing up world $it", world.exceptionOrNull()).printStackTrace() val e = Exception("Error backing up world $it", world.exceptionOrNull())
setProgress(statusData(backup.id, "$it: error")) errors.add(e)
e.printStackTrace()
return@forEach 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) backupRepository.completeBackup(backup, BackupStatus.SUCCESS)
Result.success(statusData(backup.id, "Success")) return Result.success(statusData("Backup completed successfully"))
} catch (e: Exception) { } else {
Exception("Error backing up", e).printStackTrace()
backupRepository.completeBackup(backup, BackupStatus.FAILURE) 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() return Data.Builder()
.putUuid("uuid", uuid) .putUuid("uuid", backup.id)
.putString("status", status) .putString("status", status)
.build() .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 { fun Data.Builder.putUuid(key: String, value: UUID): Data.Builder {
return this.putLongArray(key, longArrayOf(value.mostSignificantBits, value.leastSignificantBits)) 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 package eu.m724.pojavbackup.home
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
@ -10,6 +11,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.data.GameDataRepository import eu.m724.pojavbackup.core.data.GameDataRepository
import eu.m724.pojavbackup.core.datastore.SettingsRepository import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.notification.NotificationChannels
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -28,6 +30,13 @@ class HomeViewModel @Inject constructor(
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
val notificationManager =
appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
NotificationChannels.register(notificationManager)
}
fun load( fun load(
onSetupNeeded: () -> Unit 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() { fun onComplete() {
if (!viewModel.checkIfCanProceed()) return
startActivity(Intent(applicationContext, HomeActivity::class.java)) startActivity(Intent(applicationContext, HomeActivity::class.java))
finishActivity(0) finishActivity(0)
} }
@ -61,6 +76,11 @@ class SetupActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
val storagePermissionGranted = intent.getBooleanExtra("storagePermissionGranted", false)
val notificationPermissionGranted = intent.getBooleanExtra("notificationPermissionGranted", false)
viewModel.init(storagePermissionGranted, notificationPermissionGranted)
// println("Found pojav launchers: ${packages.joinToString(", ")}") // 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() 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( SetupScreen(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
viewModel = viewModel, viewModel = viewModel,
onGrantClick = { onStoragePermissionGrantClick = {
openDocumentTree.launch(defaultUri)
},
onNotificationPermissionGrantClick = {
openDocumentTree.launch(defaultUri) openDocumentTree.launch(defaultUri)
} }
) )
@ -85,7 +108,8 @@ class SetupActivity : ComponentActivity() {
fun SetupScreen( fun SetupScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SetupViewModel, viewModel: SetupViewModel,
onGrantClick: () -> Unit onStoragePermissionGrantClick: () -> Unit,
onNotificationPermissionGrantClick: () -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -100,41 +124,65 @@ fun SetupScreen(
) )
Card( GrantCard(
elevation = title = "Storage permission",
if (uiState.storagePermissionGranted) { description = "It's needed to access your saves etc.",
CardDefaults.cardElevation() granted = uiState.storagePermissionGranted,
} else { onClick = onStoragePermissionGrantClick
CardDefaults.elevatedCardElevation() )
}
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( Text(
modifier = Modifier.width(300.dp).padding( text = title,
horizontal = 20.dp, fontWeight = FontWeight.Bold,
vertical = 10.dp textAlign = TextAlign.Center,
) modifier = Modifier.fillMaxWidth()
)
Text(
text = description
)
Button(
onClick = onClick,
modifier = Modifier.align(Alignment.CenterHorizontally),
enabled = !granted
) { ) {
Text( if (granted) {
text = "Storage permission", Text("Already granted")
fontWeight = FontWeight.Bold, } else {
textAlign = TextAlign.Center, Text("Grant")
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")
}
} }
} }
} }

View file

@ -1,5 +1,6 @@
package eu.m724.pojavbackup.setup package eu.m724.pojavbackup.setup
data class SetupUiState( 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()) private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow() 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 // TODO we could make the check call separate and not pass context here
fun onOpenDocumentTree(context: Context, uri: Uri?, result: (Boolean) -> Unit) { fun onOpenDocumentTree(context: Context, uri: Uri?, result: (Boolean) -> Unit) {
if (uri != null) { if (uri != null) {