Skip to content

Commit 616a37c

Browse files
feat: ability to overwrite backups
1 parent 17a61aa commit 616a37c

File tree

12 files changed

+410
-103
lines changed

12 files changed

+410
-103
lines changed

app/src/main/java/org/androidlabs/applistbackup/BackupService.kt

Lines changed: 109 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.app.PendingIntent
66
import android.app.Service
77
import android.appwidget.AppWidgetManager
88
import android.content.ComponentName
9+
import android.content.ContentResolver
910
import android.content.Context
1011
import android.content.Intent
1112
import android.content.pm.ApplicationInfo
@@ -19,6 +20,7 @@ import android.os.Build
1920
import android.os.Environment
2021
import android.os.IBinder
2122
import android.provider.DocumentsContract
23+
import android.system.Os
2224
import android.util.Base64
2325
import android.util.Log
2426
import androidx.core.app.NotificationCompat
@@ -39,6 +41,7 @@ import org.androidlabs.applistbackup.settings.Settings
3941
import org.androidlabs.applistbackup.utils.Utils.clearPrefixSlash
4042
import org.androidlabs.applistbackup.utils.Utils.isTV
4143
import java.io.ByteArrayOutputStream
44+
import java.io.File
4245
import java.net.URLDecoder
4346
import java.nio.charset.StandardCharsets
4447
import java.text.DecimalFormat
@@ -63,7 +66,7 @@ class BackupService : Service() {
6366
const val SERVICE_CHANNEL_ID = "BackupService"
6467
const val BACKUP_CHANNEL_ID = "Backup"
6568

66-
const val FILE_NAME_PREFIX = "app-list-backup-"
69+
const val FILE_NAME_PREFIX = "app-list-backup"
6770

6871
val isRunning = MutableStateFlow(false)
6972

@@ -140,7 +143,8 @@ class BackupService : Service() {
140143
)
141144
}
142145
}
143-
return files?.map { BackupRawFile.fromFile(it, context) } ?: emptyList()
146+
return files?.map { BackupRawFile.fromFile(it, context) }
147+
?.sortedByDescending { it.lastModified } ?: emptyList()
144148
}
145149
} else {
146150
val backupsDir = DocumentFile.fromTreeUri(context, backupsUri) ?: return emptyList()
@@ -155,6 +159,7 @@ class BackupService : Service() {
155159
} == true
156160
}
157161
return files.map { BackupRawFile.fromDocumentFile(it, context) }
162+
.sortedByDescending { it.lastModified }
158163
}
159164
}
160165

@@ -163,14 +168,9 @@ class BackupService : Service() {
163168

164169
fun getLastCreatedFileUri(context: Context): Uri? {
165170
val files = getRawBackupFiles(context)
166-
if (files.isNotEmpty()) {
167-
val sortedFiles = files.sortedByDescending { it.lastModified }
168-
169-
val lastCreatedFile = sortedFiles.firstOrNull()
170-
171-
if (lastCreatedFile != null) {
172-
return lastCreatedFile.uri
173-
}
171+
val lastCreatedFile = files.firstOrNull()
172+
if (lastCreatedFile != null) {
173+
return lastCreatedFile.uri
174174
}
175175
return null
176176
}
@@ -182,38 +182,48 @@ class BackupService : Service() {
182182
.map { file ->
183183
val name = file.name
184184
val dateString = name.removePrefix(FILE_NAME_PREFIX).substringBeforeLast('.')
185-
val date = dateFormat.parse(dateString) ?: Date()
186-
val title = getTitleFromUri(file.uri) ?: name
185+
val date = if (dateString.isNotEmpty()) {
186+
dateFormat.parse(dateString) ?: Date()
187+
} else {
188+
val timestamp = getFileDate(context, file.uri)
189+
Date(timestamp)
190+
}
191+
val title = getTitleFromUri(context, file.uri) ?: name
187192
BackupFile(file.uri, date, title)
188193
}
189-
.sortedByDescending { it.date }
190194
}
191195

192-
fun getTitleFromUri(uri: Uri): String? {
196+
fun getTitleFromUri(context: Context, uri: Uri): String? {
193197
val pattern =
194-
Pattern.compile("$FILE_NAME_PREFIX(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\.(\\w+)")
198+
Pattern.compile("$FILE_NAME_PREFIX-(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\.(\\w+)")
195199
val matcher = pattern.matcher(uri.toString())
196200

197-
return if (matcher.find()) {
201+
val titleFormatter = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
202+
203+
val isFind = matcher.find()
204+
if (!isFind) {
205+
val timestamp = getFileDate(context, uri)
206+
val date = titleFormatter.format(Date(timestamp))
207+
val extension = uri.toString().split(".").last()
208+
val format = extension.let { BackupFormat.fromExtension(it) }
209+
return "$date (${format.value})"
210+
} else {
198211
val dateString = matcher.group(1)
199212
val extension = matcher.group(2)
200213

201214
val dateFormat = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
202-
val titleFormatter = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
203215

204216
val date = dateString?.let { dateFormat.parse(it) }
205217
if (date != null) {
206218
if (extension != null) {
207219
val format = extension.let { BackupFormat.fromExtension(it) }
208-
"${titleFormatter.format(date)} (${format.value})"
220+
return "${titleFormatter.format(date)} (${format.value})"
209221
} else {
210-
titleFormatter.format(date)
222+
return titleFormatter.format(date)
211223
}
212224
} else {
213-
null
225+
return null
214226
}
215-
} else {
216-
null
217227
}
218228
}
219229

@@ -228,6 +238,48 @@ class BackupService : Service() {
228238
val broadcastIntent = Intent("org.androidlabs.applistbackup.BACKUP_ACTION")
229239
context.sendBroadcast(broadcastIntent)
230240
}
241+
242+
private fun getFileDate(context: Context, uri: Uri): Long {
243+
try {
244+
when (uri.scheme) {
245+
ContentResolver.SCHEME_CONTENT -> {
246+
context.contentResolver.query(
247+
uri,
248+
arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED),
249+
null,
250+
null,
251+
null
252+
)?.use { cursor ->
253+
if (cursor.moveToFirst()) {
254+
val lastModifiedIndex = cursor.getColumnIndex(
255+
DocumentsContract.Document.COLUMN_LAST_MODIFIED
256+
)
257+
if (lastModifiedIndex != -1) {
258+
return cursor.getLong(lastModifiedIndex)
259+
}
260+
}
261+
}
262+
263+
val fileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
264+
fileDescriptor?.use {
265+
val stat = Os.fstat(it.fileDescriptor)
266+
return stat.st_mtime * 1000L
267+
}
268+
}
269+
270+
ContentResolver.SCHEME_FILE -> {
271+
val file = File(uri.path ?: "")
272+
if (file.exists()) {
273+
return file.lastModified()
274+
}
275+
}
276+
}
277+
} catch (e: Exception) {
278+
Log.e("BackupService", "Error getting file date: ${e.message}")
279+
}
280+
281+
return System.currentTimeMillis()
282+
}
231283
}
232284

233285
override fun onBind(intent: Intent?): IBinder? {
@@ -301,6 +353,7 @@ class BackupService : Service() {
301353
val excludeItems = Settings.getBackupExcludeData(this)
302354
val currentDate = Date()
303355
val currentTime = dateFormat.format(currentDate)
356+
304357
val type =
305358
getString(if (source != null && source == "tasker") R.string.automatic else R.string.manual)
306359

@@ -387,7 +440,7 @@ class BackupService : Service() {
387440

388441
formats.forEach { format ->
389442
try {
390-
val fileName = "$FILE_NAME_PREFIX$currentTime.${format.fileExtension()}"
443+
val fileName = "$FILE_NAME_PREFIX-$currentTime.${format.fileExtension()}"
391444
val newFile = backupsDir.createFile(format.mimeType(), fileName)
392445

393446
when (format) {
@@ -730,7 +783,40 @@ class BackupService : Service() {
730783
val manager = getSystemService(NotificationManager::class.java)
731784
manager.notify(getNotificationId(), endNotification)
732785
} else {
786+
val backupLimit = Settings.getBackupLimit(this)
787+
val isVersioningDisabled = backupLimit == 1
788+
789+
if (isVersioningDisabled) {
790+
successfulResults.forEach {
791+
val newFileName =
792+
"$FILE_NAME_PREFIX.${it.format.fileExtension()}"
793+
it.file?.renameTo(newFileName)?.let { newFile ->
794+
it.file = newFile
795+
}
796+
}
797+
}
798+
733799
val firstUri = successfulResults.first().file?.uri
800+
801+
if (backupLimit > 0) {
802+
val usedFormats = successfulResults.map { it.format }.distinct()
803+
val backups = getRawBackupFiles(this)
804+
805+
usedFormats.forEach { format ->
806+
val backupsByFormat = backups.filter {
807+
it.name.endsWith(format.fileExtension())
808+
}
809+
val currentCount = backupsByFormat.count()
810+
if (currentCount > backupLimit) {
811+
val filesToDeleteCount = currentCount - backupLimit
812+
val sortedBackups = backupsByFormat.sortedBy { it.lastModified }
813+
sortedBackups.take(filesToDeleteCount).forEach { backupFile ->
814+
backupFile.delete()
815+
}
816+
}
817+
}
818+
}
819+
734820
val mainActivityIntent = Intent(this, MainActivity::class.java).apply {
735821
putExtra("uri", firstUri.toString())
736822
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

app/src/main/java/org/androidlabs/applistbackup/MainActivity.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class MainActivity : FragmentActivity() {
7878
private var browseFragment: BackupReaderFragment? = null
7979
private var settingsFragment: SettingsFragment? = null
8080

81-
private val pickHtmlFile =
81+
private val pickFile =
8282
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
8383
uri?.let {
8484
val contentResolver = contentResolver
@@ -173,7 +173,14 @@ class MainActivity : FragmentActivity() {
173173
}
174174

175175
private fun onBrowse() {
176-
pickHtmlFile.launch(arrayOf("text/html"))
176+
val types = BackupFormat.entries.map { it.mimeType() }.plus(
177+
listOf(
178+
"application/csv",
179+
"application/vnd.ms-excel",
180+
"text/comma-separated-values"
181+
)
182+
)
183+
pickFile.launch(types.toTypedArray())
177184
}
178185

179186
override fun onNewIntent(intent: Intent) {
@@ -395,7 +402,6 @@ private fun MainScreen(
395402
backupContainer.visibility = View.GONE
396403
browseContainer.visibility = View.VISIBLE
397404
settingsContainer.visibility = View.GONE
398-
getBrowseFragment().loadLastBackup()
399405
}
400406

401407
Screen.Settings.route -> {

app/src/main/java/org/androidlabs/applistbackup/backupnow/BackupViewModel.kt

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,9 @@ import androidx.lifecycle.AndroidViewModel
88
import androidx.lifecycle.LiveData
99
import androidx.lifecycle.MutableLiveData
1010
import androidx.lifecycle.viewModelScope
11-
import kotlinx.coroutines.Dispatchers
12-
import kotlinx.coroutines.Job
13-
import kotlinx.coroutines.delay
1411
import kotlinx.coroutines.flow.MutableStateFlow
1512
import kotlinx.coroutines.flow.StateFlow
1613
import kotlinx.coroutines.flow.asStateFlow
17-
import kotlinx.coroutines.isActive
1814
import kotlinx.coroutines.launch
1915
import org.androidlabs.applistbackup.BackupFile
2016
import org.androidlabs.applistbackup.BackupService
@@ -36,28 +32,26 @@ class BackupViewModel(application: Application) : AndroidViewModel(application)
3632
val isBackupRunning: StateFlow<Boolean> = _isBackupRunning.asStateFlow()
3733

3834
private var backupSettingsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
39-
private var pollingJob: Job? = null
4035

4136
init {
42-
initializeFileObserver()
43-
4437
updateBackupFiles()
4538

4639
backupSettingsListener =
47-
Settings.observeBackupUri(getApplication(), ::refreshBackups)
40+
Settings.observeBackupUri(getApplication()) {
41+
refreshBackupUri()
42+
updateBackupFiles()
43+
}
4844

4945
viewModelScope.launch {
5046
BackupService.isRunning.collect { state ->
5147
_isBackupRunning.value = state
48+
if (!state) {
49+
updateBackupFiles()
50+
}
5251
}
5352
}
5453
}
5554

56-
private fun refreshBackups() {
57-
initializeFileObserver()
58-
updateBackupFiles()
59-
}
60-
6155
private fun checkNotificationEnabled(): Boolean {
6256
return notificationManagerCompat.areNotificationsEnabled()
6357
}
@@ -74,30 +68,13 @@ class BackupViewModel(application: Application) : AndroidViewModel(application)
7468
_backupUri.postValue(loadBackupUri())
7569
}
7670

77-
private fun initializeFileObserver() {
78-
pollingJob?.cancel()
79-
80-
pollingJob = viewModelScope.launch(Dispatchers.IO) {
81-
while (isActive) {
82-
val files = BackupService.getBackupFiles(getApplication())
83-
if (files.count() != (_backupFiles.value?.count() ?: 0)) {
84-
viewModelScope.launch {
85-
_backupFiles.value = files
86-
}
87-
}
88-
delay(2000)
89-
}
90-
}
91-
}
92-
9371
private fun updateBackupFiles() {
9472
val files = BackupService.getBackupFiles(getApplication())
9573
_backupFiles.value = files
9674
}
9775

9876
override fun onCleared() {
9977
super.onCleared()
100-
pollingJob?.cancel()
10178
backupSettingsListener?.let {
10279
Settings.unregisterListener(getApplication(), it)
10380
}

app/src/main/java/org/androidlabs/applistbackup/data/BackupFormatResult.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package org.androidlabs.applistbackup.data
22

33
data class BackupFormatResult(
44
val format: BackupFormat,
5-
val file: BackupRawFile?,
5+
var file: BackupRawFile?,
66
val exception: Exception?
77
) {
88
fun isSuccess(): Boolean {

0 commit comments

Comments
 (0)