Skip to content

Commit

Permalink
feat: sync media in the background
Browse files Browse the repository at this point in the history
I'm using WorkManager as the persistent job solution for the issue since it was more versatile and backwards compatible than the other option that Android proposes for data sync, that is [user initiated data jobs](https://developer.android.com/about/versions/14/changes/user-initiated-data-transfers)

Also, it has a variety of APIs for setting the work in different conditions, like internet connection and battery level, which are relevant to syncing

note: this doesn't handle the previous MediaSyncListener call that resumed the scoped storage migration process, since the migration apparatus should be removed relatively soon and therefore it isn't worth my time (nor anyone's else IMO)

So, if this is deemed as necessary for merging, consider this as `Needs a new dev` or wait for the migration code to be removed.
  • Loading branch information
BrayanDSO authored and lukstbit committed Mar 19, 2024
1 parent a1c9187 commit d227711
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 50 deletions.
1 change: 1 addition & 0 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ dependencies {
implementation 'org.nanohttpd:nanohttpd:2.3.1'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation 'com.squareup:seismic:1.0.3'
implementation "androidx.work:work-runtime-ktx:2.9.0"

// Backend libraries

Expand Down
6 changes: 5 additions & 1 deletion AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<uses-permission android:name="android.permission.GET_PACKAGE_SIZE" android:maxSdkVersion="25" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Disabled on Play Store -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<permission android:name="${applicationId}.permission.READ_WRITE_DATABASE"
Expand Down Expand Up @@ -469,7 +470,10 @@
android:theme="@style/Theme.AppCompat.NoActionBar"
android:configChanges="keyboardHidden|screenSize" />


<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
Expand Down
57 changes: 8 additions & 49 deletions AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import anki.sync.SyncAuth
import anki.sync.SyncCollectionResponse
Expand All @@ -33,6 +32,7 @@ import com.ichi2.anki.dialogs.SyncErrorDialog
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.servicelayer.ScopedStorageService
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.worker.SyncMediaWorker
import com.ichi2.async.AsyncOperation
import com.ichi2.libanki.createBackup
import com.ichi2.libanki.fullUploadOrDownload
Expand All @@ -41,9 +41,6 @@ import com.ichi2.libanki.syncLogin
import com.ichi2.libanki.utils.TimeManager
import com.ichi2.preferences.VersatileTextWithASwitchPreference
import com.ichi2.utils.NetworkUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.exceptions.BackendSyncException
import timber.log.Timber
Expand Down Expand Up @@ -210,7 +207,9 @@ private suspend fun handleNormalSync(
onCancel = ::cancelSync,
manualCancelButton = R.string.dialog_cancel
) {
withCol { syncCollection(auth2, media = syncMedia) }
withCol {
syncCollection(auth2, media = false) // media is synced by SyncMediaWorker
}
}

if (output.hasNewEndpoint()) {
Expand All @@ -229,7 +228,7 @@ private suspend fun handleNormalSync(
deckPicker.showSyncLogMessage(R.string.sync_database_acknowledge, output.serverMessage)
deckPicker.refreshState()
if (syncMedia) {
monitorMediaSync(deckPicker)
SyncMediaWorker.start(deckPicker, auth2)
}
}

Expand Down Expand Up @@ -287,7 +286,7 @@ private suspend fun handleDownload(
}
deckPicker.refreshState()
if (mediaUsn != null) {
monitorMediaSync(deckPicker)
SyncMediaWorker.start(deckPicker, auth)
}
}

Expand All @@ -314,17 +313,14 @@ private suspend fun handleUpload(
}
deckPicker.refreshState()
if (mediaUsn != null) {
monitorMediaSync(deckPicker)
SyncMediaWorker.start(deckPicker, auth)
}
}
Timber.i("Full Upload Completed")
deckPicker.showSyncLogMessage(R.string.sync_log_uploading_message, "")
}

// TODO: this needs a dedicated UI for media syncing, and needs to expose
// a way to interrupt the sync

private fun cancelMediaSync(backend: Backend) {
fun cancelMediaSync(backend: Backend) {
backend.setWantsAbort()
backend.abortMediaSync()
}
Expand All @@ -343,43 +339,6 @@ fun DeckPicker.shouldFetchMedia(preferences: SharedPreferences): Boolean {
(shouldFetchMedia == onlyIfUnmetered && !NetworkUtils.isActiveNetworkMetered())
}

private suspend fun monitorMediaSync(
deckPicker: DeckPicker
) {
val backend = CollectionManager.getBackend()
// TODO: show this in a way that is clear it can be continued in background,
// but also warn user that media files will not be available until it completes.
// TODO: provide a way for users to abort later, and see it's still going
val dialog = AlertDialog.Builder(deckPicker)
.setTitle(TR.syncMediaLogTitle())
.setMessage("")
.setPositiveButton("Background") { _, _ -> }
.setOnCancelListener { cancelMediaSync(backend) }
.show()

deckPicker.launchCatchingTask {
try {
while (true) {
// this will throw if the sync exited with an error
val resp = withContext(Dispatchers.IO) {
CollectionManager.getBackend().mediaSyncStatus()
}
if (!resp.active) {
deckPicker.onMediaSyncCompleted(SyncCompletion(isSuccess = true))
return@launchCatchingTask
}
val text = resp.progress.run { "\n$added\n$removed\n$checked" }
dialog.setMessage(text)
delay(100)
}
} catch (exc: Exception) {
deckPicker.onMediaSyncCompleted(SyncCompletion(isSuccess = false))
} finally {
dialog.dismiss()
}
}
}

/**
* Called from [DeckPicker.onMediaSyncCompleted] -> [DeckPicker.migrate] if the app is backgrounded
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2024 Brayan Oliveira <brayandso.dev@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.notifications

import com.ichi2.annotations.NeedsTest

@NeedsTest("ensure the values are unique")
object NotificationId {
const val SYNC_MEDIA = 123
}
168 changes: 168 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2024 Brayan Oliveira <brayandso.dev@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.worker

import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import anki.sync.MediaSyncProgress
import anki.sync.SyncAuth
import anki.sync.syncAuth
import com.ichi2.anki.Channel
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.R
import com.ichi2.anki.cancelMediaSync
import com.ichi2.anki.notifications.NotificationId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import timber.log.Timber

class SyncMediaWorker(
context: Context,
parameters: WorkerParameters
) : CoroutineWorker(context, parameters) {

private val workManager = WorkManager.getInstance(context)
private val notificationManager = NotificationManagerCompat.from(context)

override suspend fun doWork(): Result {
Timber.v("SyncMediaWorker::doWork")
setForeground(getForegroundInfo())

try {
val syncAuth = syncAuth {
hkey = inputData.getString(HKEY_KEY)!!
inputData.getString(ENDPOINT_KEY)?.let {
endpoint = it
}
}
withCol { backend.syncMedia(syncAuth) }
val backend = CollectionManager.getBackend()
var syncProgress: MediaSyncProgress? = null
while (true) {
val status = backend.mediaSyncStatus()
if (!status.active) {
hideNotification()
return Result.success()
}
// avoid sending repeated notifications
if (syncProgress != status.progress) {
syncProgress = status.progress
// TODO display better the result. In tests, using setContentText lead to
// truncated text if it had more than two lines.
// `added`, `removed` and `checked` already come translated from the backend
val notificationText = syncProgress.run { "$added $removed $checked" }
notify(getProgressNotification(notificationText))
}
delay(100)
}
} catch (cancellationException: CancellationException) {
Timber.w(cancellationException)
cancelMediaSync(CollectionManager.getBackend())
hideNotification()
throw cancellationException
} catch (throwable: Throwable) {
Timber.w(throwable)
notify(buildNotification { setContentTitle(CollectionManager.TR.syncMediaFailed()) })
return Result.failure()
}
}

override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = buildNotification {
setContentTitle(applicationContext.getString(R.string.syncing_media))
setOngoing(true)
setProgress(0, 0, true)
foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(NotificationId.SYNC_MEDIA, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NotificationId.SYNC_MEDIA, notification)
}
}

private fun notify(notification: Notification) =
notificationManager.notify(NotificationId.SYNC_MEDIA, notification)

private fun hideNotification() =
notificationManager.cancel(NotificationId.SYNC_MEDIA)

private fun buildNotification(block: NotificationCompat.Builder.() -> Unit): Notification {
return NotificationCompat.Builder(applicationContext, Channel.SYNC.id).apply {
priority = NotificationCompat.PRIORITY_LOW
setSmallIcon(R.drawable.ic_star_notify)
setCategory(NotificationCompat.CATEGORY_PROGRESS)
setSilent(true)
contentView
block()
}.build()
}

private fun getProgressNotification(progress: CharSequence): Notification {
val title = applicationContext.getString(R.string.syncing_media)

val cancelTitle = applicationContext.getString(R.string.dialog_cancel)
val cancelIntent = workManager.createCancelPendingIntent(id)

return buildNotification {
setContentTitle(title)
setContentText(progress)
setOngoing(true)
addAction(R.drawable.close_icon, cancelTitle, cancelIntent)
}
}

companion object {
private const val HKEY_KEY = "hkey"
private const val ENDPOINT_KEY = "endpoint"

fun start(context: Context, syncAuth: SyncAuth) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

val data = Data.Builder()
.putString(HKEY_KEY, syncAuth.hkey)
.putString(ENDPOINT_KEY, syncAuth.endpoint)
.build()

val request = OneTimeWorkRequestBuilder<SyncMediaWorker>()
.setInputData(data)
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()

WorkManager.getInstance(context)
.beginUniqueWork(UniqueWorkNames.SYNC_MEDIA, ExistingWorkPolicy.KEEP, request)
.enqueue()
}
}
}
23 changes: 23 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/worker/UniqueWorkNames.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2024 Brayan Oliveira <brayandso.dev@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.worker

import com.ichi2.annotations.NeedsTest

@NeedsTest("constant values are unique")
object UniqueWorkNames {
const val SYNC_MEDIA = "syncMedia"
}
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values/01-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
<string name="continue_sync" tools:ignore="UnusedResources">Continue sync</string>
<string name="sync_cancelled" tools:ignore="UnusedResources">Sync cancelled</string>
<string name="sync_cancel_message" tools:ignore="UnusedResources">Cancelling…\nThis may take some time.</string>
<string name="syncing_media">Syncing media</string>
<string name="export_deck">Export deck</string>
<string name="nothing">Nothing</string>

Expand Down

0 comments on commit d227711

Please sign in to comment.