Compare commits

...

1 Commits

Author SHA1 Message Date
Santo Shakil
bba46d5c62 retry background backup when the network comes back 2026-07-21 00:03:58 +06:00
10 changed files with 187 additions and 42 deletions

View File

@@ -198,19 +198,22 @@ class FlutterError (
/** Generated class from Pigeon that represents data sent in messages. */
data class BackgroundWorkerSettings (
val requiresCharging: Boolean,
val requiresUnmetered: Boolean,
val minimumDelaySeconds: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BackgroundWorkerSettings {
val requiresCharging = pigeonVar_list[0] as Boolean
val minimumDelaySeconds = pigeonVar_list[1] as Long
return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
val requiresUnmetered = pigeonVar_list[1] as Boolean
val minimumDelaySeconds = pigeonVar_list[2] as Long
return BackgroundWorkerSettings(requiresCharging, requiresUnmetered, minimumDelaySeconds)
}
}
fun toList(): List<Any?> {
return listOf(
requiresCharging,
requiresUnmetered,
minimumDelaySeconds,
)
}
@@ -222,12 +225,13 @@ data class BackgroundWorkerSettings (
return true
}
val other = other as BackgroundWorkerSettings
return BackgroundWorkerPigeonUtils.deepEquals(this.requiresCharging, other.requiresCharging) && BackgroundWorkerPigeonUtils.deepEquals(this.minimumDelaySeconds, other.minimumDelaySeconds)
return BackgroundWorkerPigeonUtils.deepEquals(this.requiresCharging, other.requiresCharging) && BackgroundWorkerPigeonUtils.deepEquals(this.requiresUnmetered, other.requiresUnmetered) && BackgroundWorkerPigeonUtils.deepEquals(this.minimumDelaySeconds, other.minimumDelaySeconds)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + BackgroundWorkerPigeonUtils.deepHash(this.requiresCharging)
result = 31 * result + BackgroundWorkerPigeonUtils.deepHash(this.requiresUnmetered)
result = 31 * result + BackgroundWorkerPigeonUtils.deepHash(this.minimumDelaySeconds)
return result
}
@@ -256,7 +260,7 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi {
fun enable()
fun enable(settings: BackgroundWorkerSettings)
fun saveNotificationMessage(title: String, body: String)
fun configure(settings: BackgroundWorkerSettings)
fun disable()
@@ -273,9 +277,11 @@ interface BackgroundWorkerFgHostApi {
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val settingsArg = args[0] as BackgroundWorkerSettings
val wrapped: List<Any?> = try {
api.enable()
api.enable(settingsArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)

View File

@@ -7,9 +7,15 @@ import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.MoreExecutors
import io.flutter.embedding.engine.FlutterEngineCache
import java.util.concurrent.TimeUnit
@@ -18,9 +24,8 @@ private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
override fun enable() {
enqueueMediaObserver(ctx)
enqueuePeriodicWorker(ctx)
override fun enable(settings: BackgroundWorkerSettings) {
applySettings(settings)
}
override fun saveNotificationMessage(title: String, body: String) {
@@ -28,9 +33,14 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
}
override fun configure(settings: BackgroundWorkerSettings) {
applySettings(settings)
}
private fun applySettings(settings: BackgroundWorkerSettings) {
BackgroundWorkerPreferences(ctx).updateSettings(settings)
enqueueMediaObserver(ctx)
enqueuePeriodicWorker(ctx)
refreshQueuedBackgroundWorker(ctx)
}
override fun disable() {
@@ -95,17 +105,42 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
}
fun enqueueBackgroundWorker(ctx: Context) {
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
val work = OneTimeWorkRequestBuilder<BackgroundWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
val work = backgroundWorkerRequestBuilder(ctx).build()
WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
}
private fun backgroundWorkerRequestBuilder(ctx: Context): OneTimeWorkRequest.Builder {
val settings = BackgroundWorkerPreferences(ctx).getSettings()
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (settings.requiresUnmetered) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
return OneTimeWorkRequestBuilder<BackgroundWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
}
private fun refreshQueuedBackgroundWorker(ctx: Context) {
val workManager = WorkManager.getInstance(ctx)
Futures.addCallback(
workManager.getWorkInfosForUniqueWork(BACKGROUND_WORKER_NAME),
object : FutureCallback<List<WorkInfo>> {
override fun onSuccess(infos: List<WorkInfo>?) {
val info = infos?.firstOrNull { it.state == WorkInfo.State.ENQUEUED } ?: return
workManager.updateWork(backgroundWorkerRequestBuilder(ctx).setId(info.id).build())
}
override fun onFailure(t: Throwable) {
Log.w(TAG, "Failed to update background worker", t)
}
},
MoreExecutors.directExecutor(),
)
}
fun isBackgroundWorkerRunning(): Boolean {
// Easier to check if the engine is cached as we always cache the engine when starting the worker
// and remove it when the worker is finished

View File

@@ -9,12 +9,14 @@ class BackgroundWorkerPreferences(private val ctx: Context) {
const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
private const val SHARED_PREF_REQUIRE_UNMETERED_KEY = "BackgroundWorker::requireUnmetered"
private const val SHARED_PREF_LOCK_KEY = "BackgroundWorker::isLocked"
private const val SHARED_PREF_NOTIF_TITLE_KEY = "BackgroundWorker::notificationTitle"
private const val SHARED_PREF_NOTIF_MSG_KEY = "BackgroundWorker::notificationMessage"
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
private const val DEFAULT_REQUIRE_CHARGING = false
private const val DEFAULT_REQUIRE_UNMETERED = true
private const val DEFAULT_NOTIF_TITLE = "Uploading media"
private const val DEFAULT_NOTIF_MSG = "Checking for new assets…"
}
@@ -27,6 +29,7 @@ class BackgroundWorkerPreferences(private val ctx: Context) {
sp.edit {
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
putBoolean(SHARED_PREF_REQUIRE_UNMETERED_KEY, settings.requiresUnmetered)
}
}
@@ -39,6 +42,10 @@ class BackgroundWorkerPreferences(private val ctx: Context) {
SHARED_PREF_REQUIRE_CHARGING_KEY,
DEFAULT_REQUIRE_CHARGING
),
requiresUnmetered = sp.getBoolean(
SHARED_PREF_REQUIRE_UNMETERED_KEY,
DEFAULT_REQUIRE_UNMETERED
),
)
}
@@ -66,4 +73,3 @@ class BackgroundWorkerPreferences(private val ctx: Context) {
return sp.getBoolean(SHARED_PREF_LOCK_KEY, true)
}
}

View File

@@ -163,22 +163,26 @@ func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
/// Generated class from Pigeon that represents data sent in messages.
struct BackgroundWorkerSettings: Hashable {
var requiresCharging: Bool
var requiresUnmetered: Bool
var minimumDelaySeconds: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
let requiresCharging = pigeonVar_list[0] as! Bool
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
let requiresUnmetered = pigeonVar_list[1] as! Bool
let minimumDelaySeconds = pigeonVar_list[2] as! Int64
return BackgroundWorkerSettings(
requiresCharging: requiresCharging,
requiresUnmetered: requiresUnmetered,
minimumDelaySeconds: minimumDelaySeconds
)
}
func toList() -> [Any?] {
return [
requiresCharging,
requiresUnmetered,
minimumDelaySeconds,
]
}
@@ -186,12 +190,13 @@ struct BackgroundWorkerSettings: Hashable {
if Swift.type(of: lhs) != Swift.type(of: rhs) {
return false
}
return deepEqualsBackgroundWorker(lhs.requiresCharging, rhs.requiresCharging) && deepEqualsBackgroundWorker(lhs.minimumDelaySeconds, rhs.minimumDelaySeconds)
return deepEqualsBackgroundWorker(lhs.requiresCharging, rhs.requiresCharging) && deepEqualsBackgroundWorker(lhs.requiresUnmetered, rhs.requiresUnmetered) && deepEqualsBackgroundWorker(lhs.minimumDelaySeconds, rhs.minimumDelaySeconds)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BackgroundWorkerSettings")
deepHashBackgroundWorker(value: requiresCharging, hasher: &hasher)
deepHashBackgroundWorker(value: requiresUnmetered, hasher: &hasher)
deepHashBackgroundWorker(value: minimumDelaySeconds, hasher: &hasher)
}
}
@@ -234,7 +239,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
func enable(settings: BackgroundWorkerSettings) throws
func saveNotificationMessage(title: String, body: String) throws
func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
@@ -248,9 +253,11 @@ class BackgroundWorkerFgHostApiSetup {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableChannel.setMessageHandler { _, reply in
enableChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let settingsArg = args[0] as! BackgroundWorkerSettings
do {
try api.enable()
try api.enable(settings: settingsArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))

View File

@@ -2,7 +2,7 @@ import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
func enable(settings: BackgroundWorkerSettings) throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundWorkerApiImpl:enable Background worker scheduled")

View File

@@ -33,22 +33,25 @@ class BackgroundWorkerFgService {
const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed
Future<void> enable() => _foregroundHostApi.enable();
Future<void> enable() => _foregroundHostApi.enable(_currentSettings());
Future<void> saveNotificationMessage(String title, String body) =>
_foregroundHostApi.saveNotificationMessage(title, body);
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) {
final backup = SettingsRepository.instance.appConfig.backup;
return _foregroundHostApi.configure(
BackgroundWorkerSettings(
minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay,
requiresCharging: requireCharging ?? backup.requireCharging,
),
);
}
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
_currentSettings(minimumDelaySeconds: minimumDelaySeconds, requireCharging: requireCharging),
);
Future<void> disable() => _foregroundHostApi.disable();
BackgroundWorkerSettings _currentSettings({int? minimumDelaySeconds, bool? requireCharging}) {
final backup = SettingsRepository.instance.appConfig.backup;
return BackgroundWorkerSettings(
minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay,
requiresCharging: requireCharging ?? backup.requireCharging,
requiresUnmetered: !(backup.useCellularForPhotos || backup.useCellularForVideos),
);
}
}
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {

View File

@@ -97,14 +97,20 @@ int _deepHash(Object? value) {
}
class BackgroundWorkerSettings {
BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
BackgroundWorkerSettings({
required this.requiresCharging,
required this.requiresUnmetered,
required this.minimumDelaySeconds,
});
bool requiresCharging;
bool requiresUnmetered;
int minimumDelaySeconds;
List<Object?> _toList() {
return <Object?>[requiresCharging, minimumDelaySeconds];
return <Object?>[requiresCharging, requiresUnmetered, minimumDelaySeconds];
}
Object encode() {
@@ -113,7 +119,11 @@ class BackgroundWorkerSettings {
static BackgroundWorkerSettings decode(Object result) {
result as List<Object?>;
return BackgroundWorkerSettings(requiresCharging: result[0]! as bool, minimumDelaySeconds: result[1]! as int);
return BackgroundWorkerSettings(
requiresCharging: result[0]! as bool,
requiresUnmetered: result[1]! as bool,
minimumDelaySeconds: result[2]! as int,
);
}
@override
@@ -126,6 +136,7 @@ class BackgroundWorkerSettings {
return true;
}
return _deepEquals(requiresCharging, other.requiresCharging) &&
_deepEquals(requiresUnmetered, other.requiresUnmetered) &&
_deepEquals(minimumDelaySeconds, other.minimumDelaySeconds);
}
@@ -173,7 +184,7 @@ class BackgroundWorkerFgHostApi {
final String pigeonVar_messageChannelSuffix;
Future<void> enable() async {
Future<void> enable(BackgroundWorkerSettings settings) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -181,7 +192,7 @@ class BackgroundWorkerFgHostApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[settings]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);

View File

@@ -192,30 +192,34 @@ class _BackupSwitchTile extends ConsumerWidget {
}
}
class _UseCellularForVideosButton extends StatelessWidget {
class _UseCellularForVideosButton extends ConsumerWidget {
const _UseCellularForVideosButton();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fgService = ref.read(backgroundWorkerFgServiceProvider);
return _BackupSwitchTile(
metadataKey: SettingsKey.backupUseCellularForVideos,
selector: (c) => c.backup.useCellularForVideos,
titleKey: "videos",
subtitleKey: "network_requirement_videos_upload",
onChanged: (_) => fgService.configure(),
);
}
}
class _UseCellularForPhotosButton extends StatelessWidget {
class _UseCellularForPhotosButton extends ConsumerWidget {
const _UseCellularForPhotosButton();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fgService = ref.read(backgroundWorkerFgServiceProvider);
return _BackupSwitchTile(
metadataKey: SettingsKey.backupUseCellularForPhotos,
selector: (c) => c.backup.useCellularForPhotos,
titleKey: "photos",
subtitleKey: "network_requirement_photos_upload",
onChanged: (_) => fgService.configure(),
);
}
}

View File

@@ -13,14 +13,19 @@ import 'package:pigeon/pigeon.dart';
)
class BackgroundWorkerSettings {
final bool requiresCharging;
final bool requiresUnmetered;
final int minimumDelaySeconds;
const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
const BackgroundWorkerSettings({
required this.requiresCharging,
required this.requiresUnmetered,
required this.minimumDelaySeconds,
});
}
@HostApi()
abstract class BackgroundWorkerFgHostApi {
void enable();
void enable(BackgroundWorkerSettings settings);
void saveNotificationMessage(String title, String body);

View File

@@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../repository_context.dart';
class _MockBackgroundWorkerFgHostApi extends Mock implements BackgroundWorkerFgHostApi {}
void main() {
late MediumRepositoryContext ctx;
late _MockBackgroundWorkerFgHostApi hostApi;
late BackgroundWorkerFgService sut;
setUpAll(() async {
ctx = MediumRepositoryContext();
await SettingsRepository.ensureInitialized(ctx.db);
registerFallbackValue(
BackgroundWorkerSettings(requiresCharging: false, requiresUnmetered: true, minimumDelaySeconds: 30),
);
});
tearDownAll(() async {
await ctx.dispose();
});
setUp(() {
hostApi = _MockBackgroundWorkerFgHostApi();
when(() => hostApi.enable(any())).thenAnswer((_) async {});
when(() => hostApi.configure(any())).thenAnswer((_) async {});
sut = BackgroundWorkerFgService(hostApi);
});
group('BackgroundWorkerFgService network constraint', () {
Future<bool> configuredRequiresUnmetered({required bool photos, required bool videos}) async {
await SettingsRepository.instance.write(.backupUseCellularForPhotos, photos);
await SettingsRepository.instance.write(.backupUseCellularForVideos, videos);
await sut.configure();
final settings = verify(() => hostApi.configure(captureAny())).captured.single as BackgroundWorkerSettings;
return settings.requiresUnmetered;
}
test('requires unmetered by default', () async {
expect(await configuredRequiresUnmetered(photos: false, videos: false), isTrue);
});
test('does not require unmetered when photos may use cellular', () async {
expect(await configuredRequiresUnmetered(photos: true, videos: false), isFalse);
});
test('does not require unmetered when videos may use cellular', () async {
expect(await configuredRequiresUnmetered(photos: false, videos: true), isFalse);
});
test('does not require unmetered when both photos and videos may use cellular', () async {
expect(await configuredRequiresUnmetered(photos: true, videos: true), isFalse);
});
test('enable carries the same constraint at startup', () async {
await SettingsRepository.instance.write(.backupUseCellularForPhotos, true);
await SettingsRepository.instance.write(.backupUseCellularForVideos, true);
await sut.enable();
final settings = verify(() => hostApi.enable(captureAny())).captured.single as BackgroundWorkerSettings;
expect(settings.requiresUnmetered, isFalse);
});
});
}