Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

upload new photos in background with a service #382

Merged
merged 4 commits into from
Aug 18, 2022

Conversation

fyfrey
Copy link
Contributor

@fyfrey fyfrey commented Jul 27, 2022

Background backup service

This is more proper attempt at automatically uploading new photos in a background service even if the app is not open. Partly fixes issue #235. To enable this feature, click the new enable background backup button on the Backup screen.

Only works on Android!

Overview: How does this work

Androids WorkManager is used to run a Kotlin/Java service whenever a new photo/video is stored on the device (by taking a picture or receiving an image via some messaging app) or modified (by editing an existing image). This class BackupWorker starts the Dart engine which then performs the actual backup in Dart (with BackgroundService and BackupService).

Battery efficiency

The backup logic in the foreground app currently scans all photos/videos on the device to perform, the backup. Doing this every time a photo is taken, would eat up the users battery very quickly. To solve this issue, the background backup considers only assets newer (modified date) than the last time the corresponding album has successfully been backed up.

Error handling

The background service is only executed by Android if all constraints are met (e.g. battery not low, wifi connection etc., this is already configurable in Dart code, but not in the UI). Whenever these constraints no longer hold (e.g. by losing wifi connection), Android stops the BackupWorker. The backup process is correctly shut down and newly enqueued (without waiting for a new photo to be taken!) to run once the other constraints are fulfilled.
Errors during the background backup (e.g. server becoming unavailable) produce a native notification: Failed to upload file ... etc.

Concurrency issues

Since HiveDB does not support concurrent access, manual locking is implemented via message passing between the foreground and background thread. Which thread first takes the lock (gains exclusive access) proceeds. The background service simply waits until the foreground app is no longer active. In the foreground app, certain operations are not available while the service is running / has the lock: Changing the albums to back up and starting/continuing the backup process in the foreground. These operations become automatically available as soon as the background service finishes / releases the lock.

@@ -314,7 +314,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {

// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(
await _backupService.backupAsset(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this to allow multiple assets to be uploaded at the same time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for error handling, I had the most strange debugging issues (and crashes) without all async functions returning a Future and awaiting other async functions

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it only when running as a background service?

Copy link
Contributor Author

@fyfrey fyfrey Jul 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have the issues only when running as background service. The change would of course affect both. I have just re-checked if this await is still necessary for running as a service: Yes. If I remove it, running startBackupProcess fails/stops the executing thread (in an asynchronous error? withour any stacktrace)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finally figured out what's causing the different behavior (background service vs in foreground app):
The background process is started by the Android WorkManager and executes the new Java/Kotlin BackupWorker. This starts the Dart/Flutter runtime in a new thread. I Had to make the worker aware of running another async thread by not immediately stopping after finishing the code on the Java/Kotlin side. Instead a ResolvableFuture is used to tell the Android system that the process completed (successfully or failed). This signal is send by the dart/flutter thread once it finishes executing (i.e. return Future.value(true); in callbackDispatcher method handler). If the executed dart code calls an async function without await this function is executed in yet another thread and the main dart code continues, finished, and in turn signals Android that the background work is finished. The Android system then kills the entire process. Yet, this non-main async dart function (e.g. backupAsset) was still running and is killed along with its process.

tl;dr: all async functions need to be awaited else the thread/process is killed while executing them in the background.

Not await an async function is also a problem in the foreground app: If an error occurs in that function, it is silently ignored!

@fyfrey
Copy link
Contributor Author

fyfrey commented Jul 28, 2022

@alextran1502 I've made some improvements to the service and its handling. However, before continuing further, I'd rather like to plan with you how to integrate the background service nicely. Both code/architecture and user experience/interface aspects.

@alextran1502
Copy link
Contributor

@zoodyy Agree, I haven't had a chance to go through the code fully. I will let you know as soon as I've done that. I've noticed a few things we might be able to make this simpler on the implementation side.

Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);

var isAuthenticated =
await container.read(authenticationProvider.notifier).login(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we don't need to use riverpod in the background service since Riverpod is for state management. We can use the generated OpenAPI SDK to make request directly to the server.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can take a look at ApiService class at /mobile/lib/shared/services/api.services.dart

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that could be an option. Could lead to some code duplication (backup/uploading in app foreground vs service would take separate paths?).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fair point. Let's keep the current backup code flow

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping the current backup code contradicts with the following, or does it not?

  1. How to make the background service as lean as possible

With the current ApiService, we can do this very easily.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the app code at all - can you not just call into the existing backup code? (Or refactor it a bit to make that possible)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's possible, that's currently what this PR does. but it has its issues (see 5.)

@alextran1502
Copy link
Contributor

alextran1502 commented Jul 29, 2022

What would trigger the background service to start running? What are some points you would like to discuss? What is the recommended testing method for this feature?

@@ -412,6 +412,7 @@ class BackupControllerPage extends HookConsumerWidget {

void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
// TODO Android: Make sure background backup service is disabled & currently not running!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we disable the background service as soon as the app is resumed? It that the mechanism "baked" into the background service implementation on the native side, such that when the app is in foreground, the background service would pause or stop running?

Do you have any good resouce to read on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether to disable the service once the app is opened is good question. This is mostly a concurrency issue. What we must prevent is that the background service is uploading something, user opens the app and the foreground app also performs an upload (of potentially the same files). There are multiple options to consider:

  1. When user opens the app: Stop the service (and kill any currently happening uploads), wait until stopped, check which assets to backup and perform upload as usual in the foreground app. Start service once app is closed ("inactive" / "paused" state)
  2. Remove foreground backup/upload (on Android) in the app entirely
  3. Let the user decide whether to use either only foreground upload or use only the background service

Of these options, I think 2. is cleanest (and simplest), 1. is very difficult to get right and potentially wasteful regarding battery as uploads might be canceled and performed twice, 3. is nice as a safeguard if the background upload has issues (so manual foreground upload still works as is) and difficulty is somewhere between the other two options

It that the mechanism "baked" into the background service implementation on the native side, such that when the app is in foreground, the background service would pause or stop running?

There is no automatic stopping (or pausing) of the background service. It runs entirely independent of the app.
Sorry, no good single resource to read (the Android documentation on services might even be confusing due to using the word "foreground" for services..)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like option 1 the best because it makes the most sense to the flow of the app.

I'd like to retain the ability to let the user perform the backup as they like and have to choose to opt-in to run background backup so that we don't force users to a fixed workflow.

Copy link
Contributor

@alextran1502 alextran1502 Jul 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can set a flag in the persistent storage of the app (Hive) that the users are using the app and the app is in foreground, background service can check that before performing any action. And vice versa when the users invoke the backup function from the app

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if Hive works fine with concurrent access by different processes, this is good. However, it seems boxes need to be closed before opening in another process.

Copy link
Member

@bo0tzz bo0tzz Jul 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have an RPC connection to the background service? You could maybe implement uploading entirely in there and only tell the service to actually do work when the app is open. (Or freely when you enable background upload). You could still have it look like the current foreground upload that way when needed.

Edit: I just read the comment below about when the background service is run, based on that I guess my suggestion is probably not feasible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out, this might actually be possible. I've figured out a way to setup message passing between the two threads (foreground and background service)!
I think should be done in a follow-up PR once the background service is well tested and has a counter part on iOS.

@fyfrey
Copy link
Contributor Author

fyfrey commented Jul 29, 2022

What would trigger the background service to start running?

Current setup is the following: User activates automatic backup in the app, this enqueues the BackupWorker in the Android WorkManager. Whenever a photo/video is changed on the device, the Android OS starts the BackupWorker (and provides a list of the changed photos/videos). I further added configurable constraints such as battery not low, device is charging (not required by default currently), device has unmetered network connection (WIFI). Only when all constraints are met, the BackupWorker is executed by the system. It has some builtin waiting period (several seconds, I could check if its configurable in the WorkManager API if we wanted to increase it) before starting the worker after a new picture is added so that it is not executed for every single file when taking multiple pictures in a row. Once the service is active, it automatically enqueues itself again with the same constraints. This is necessary because the file-changed constraints are not available to periodic tasks but only to one-time tasks.
We could actually configure the constraints for changed (added, moved, deleted) photos/video files, such that only folders that are selected for backup in the app are monitored by system. Have not done that yet because it is more work :-)

An alternative (simpler) approach would be to let the system execute the service every x > 15 minutes. This is the minimum interval for background services on Android. However, this is quite wasteful regarding battery usage when not taking any pictures AND backups are delayed longer after taking a photo.

What are some points you would like to discuss?

  1. How to handle app and background service potentially performing backup at the same time (already doing that in the comments you started in the other discussion thread
  2. Is communication between background service and foreground app necessary?: Currently not implemented and I am honestly not sure if it is possible (it is somehow possible between native Android app/service and between Dart/Flutter isolates... but might be very challenging in combination)
  3. Does the background service need update any persistent state of the app? Is there persisted state e.g. which local files are already backed up to the server, ..) I've read that opening the same hive store concurrently is not supported.
  4. How to let the user decide/configure the background service and its constraints (mostly UI)
  5. How to make the background service as lean as possible: Not loading riverpod and all services etc. (no login, no checking all assets on device etc.) Simply load auth Bearer token from hive/sharedpreferences etc., make API calls to upload all new photos (as given by files changed info from Android WorkManager), optionally update local persistent state (which files are backed up), exit. This is very important to preserve battery life because currently it is too costly to upload a single new photo.
  6. BackgroundService is currently a util with only static methods and two top-level methods. Not sure whether this fits in well. Maybe change the service to a singleton or put it somewhere else (currently modules/backup/services)? That one top-level method (as stated in its documentation comment) needs to stay, however.
  7. Error handling in the background service: First, let the WorkManager retry the task after some time. If an unrecoverable errors occurs / number of retries exhausted, stop the service & show a native notification to the user: Clicking on it opens the app to possible show more info and let the app enqueue the service again?
  8. Edge case handling: If the service uploads only changed files notified by the system, some files will be missed (e.g. after stopping the app by removing it from recent apps, after errors in background upload, etc.). Thus, I feel it is sometimes necessary to check all assets (as it is currently done) and upload all files that have been missed. This could be done in foreground when the user opens the app - this has issues as discussed in 1. here. Maybe schedule a periodic task or execute it on app start once per day: This task pauses the normal BackupWorker, performs the full backup with finding any missed files, reenabled the normal BackupWorker, exits.

What is the recommended testing method for this feature?

Start the app, click "Turn on backup" in the backup settings, switch to another app, e.g. camera, take a photo, wait ~10 seconds (might be device depended), (optionally look at app debug log, currently there should be [resumeBackup] Start back up and Worker result SUCCESS for Work [ id=..., tags={ app.alextran.immich.BackupWorker } ]), check that the newly taken photo is backed up the server by looking at the web app.
Maybe the whole process could somehow be tested automatically with an E2E integration test.. I'm not familiar with automated testing on mobile though.

@alextran1502
Copy link
Contributor

alextran1502 commented Jul 29, 2022

1. How to handle app and background service potentially performing backup at the same time
We will need to figure out a way to handle communication between the two, ideally through persistent storage (Hive/SharePreference)

2. Is communication between background service and foreground app necessary?: Currently not implemented and I am honestly not sure if it is possible (it is somehow possible between native Android app/service and between Dart/Flutter isolates... but might be very challenging in combination)
Yes, we will need this to make the UI as sensible as possible

3. Does the background service need update any persistent state of the app? Is there persisted state e.g. which local files are already backed up to the server, ..) I've read that opening the same hive store concurrently is not supported.
I would say no. Before uploading, workers can query which files are in the database and start backup files that are not. This is already built in the backup workflow

4. How to let the user decide/configure the background service and its constraints (mostly UI)
I think this can be set in an app's options page, which still needed to be built. Once we have the mechanism down, I can handle this task

5. How to make the background service as lean as possible: Not loading riverpod and all services etc. (no login, no checking all assets on device etc.) Simply load auth Bearer token from hive/sharedpreferences etc., make API calls to upload all new photos (as given by files changed info from Android WorkManager), optionally update local persistent state (which files are backed up), exit. This is very important to preserve battery life because currently it is too costly to upload a single new photo.

With the current ApiService, we can do this very easily.

6. BackgroundService is currently a util with only static methods and two top-level methods. Not sure whether this fits in well. Maybe change the service to a singleton or put it somewhere else (currently modules/backup/services)? That one top-level method (as stated in its documentation comment) needs to stay, however.

I can imagine that we will eventually have some more background services or another background service with different logic for iOS, so we can put them in module/backup/background_service/[android/ios]_background.service.dart

7. Error handling in the background service: First, let the WorkManager retry the task after some time. If an unrecoverable errors occurs / number of retries exhausted, stop the service & show a native notification to the user: Clicking on it opens the app to possible show more info and let the app enqueue the service again?
This sounds good to me.

8. Edge case handling: If the service uploads only changed files notified by the system, some files will be missed (e.g. after stopping the app by removing it from recent apps, after errors in background upload, etc.). Thus, I feel it is sometimes necessary to check all assets (as it is currently done) and upload all files that have been missed. This could be done in foreground when the user opens the app - this has issues as discussed in #382 (comment). Maybe schedule a periodic task or execute it on app start once per day: This task pauses the normal BackupWorker, performs the full backup with finding any missed files, reenabled the normal BackupWorker, exits.

Why don't we always check for all assets with the server before uploading in background service as it is being done in the foreground? This ensures that the most recent information is up to date.

@fyfrey
Copy link
Contributor Author

fyfrey commented Jul 29, 2022

Thanks for the extensive answers!

  1. I'll try out accessing Hive boxes at the same time and see if any issues arise
  2. I guess if the background upload is stopped while the app is open, we do not need live process-to-process communication, and communication via persistent state is enough?
  3. Okay, so every photo on the device is checked whether it already exists on the server every time the backup runs? Seems less than ideal from an efficiency perspective... however, it keeps local state very small :-)
  4. Alright, I can wait for the option page. An alternative could be to put the options on the backup page (you can already select which albums to backup, how/when to backup all albums also makes sense in that context)
  5. ApiService looks good! Remains the question if/how to skip the backupProvider... currently startBackupProcess seems to me like a very costly method doing a lot of work (getBackupInfo and building the set of assetsWillBeBackup) to derive which files to upload. I'll measure this on my phone with a few thousand photos to see if there are substantial improvements to be gained. This is not an issue if done once per app opening, but it will negatively impact the battery usage when the service executes frequently (due to taking new photos). The background service retrieves this set of new files almost for free from the system. This could be used to create AssetEntitys from the file URLs and call _backupService.backupAsset (or the ApiService directly with the necessary info). As an alternative I could try to make the current backup code more efficient (that would benefit both foreground and background backup)
  6. Yeah, iOS works quite different as far as I know. Putting the background services in their own folder seems a good choice to not confuse them with the existing riverpod services
  7. I'll look into building such a notification that opens the app and either store the error information in a Hive box or send it along with the app opening intent
  8. We can certainly do that (this is currently implemented in this PR). This edge case handling would only be an issue if the background service does not check all photos for battery efficiency reasons (see 5.) and foreground backup would be disabled. Since you prefer to keep the foreground upload active all the time, these edge cases are also resolved automatically by opening the app.

@fyfrey
Copy link
Contributor Author

fyfrey commented Jul 30, 2022

Did some performance testing to check if the current backup preparation (code in method startBackupProcess until _backupService.backupAsset is called) takes too much time/CPU/battery to run often (as a potentially frequently running background service). Experiment environment: release build of immich, ~6000 photos on the device and on the server, triggering a start of the background service by taking a new picture.

total  overhead  getBackupInfo  assetsWillBeBackup
 2561      2473           1469                 837
 2357      2253            882                1248
 2829      2764           1258                1359

All measurements are in milliseconds (3 runs in total). total is the time from the line in Kotlin BackupWorker.startWork until last line in Kotlin BackupWorker.stopEngine. overhead is the entire time spent in _onPhotosChanged (opening Hive boxes, instantiating riverpod services, calling backupProvider.resumeBackup until _backupService.backupAsset is called (where the actual backup is performed). getBackupInfo is the time taken by that function in backupProvider (note: it's actually called twice in parallel, once by instatiating the backupProvider, once by calling startBackupProcess, thus multiply its value by 2 to get CPU time usage). assetsWillBeBackup refers to time taken in startBackupProcess where the set of assets to upload is created (by removing assets already present on the server).

Using the existing backup logic burns (when already optimizing the duplicate call to getBackupInfo) roughly 2.5 seconds worth of CPU time (and battery!). This heavy cost is paid every time the background service is triggered to upload any number of new photos (usually just 1). Thus, I'll definitely look into ways to significantly reduce these 2.5 seconds, otherwise using the background service eats up the users phone battery too quickly. Most likely I'll use the list of changed assets given by Android when calling the worker and directly calling _backupService.backupAsset with a set of new assets (after verifying these are not already backed up to the server).

@alextran1502
Copy link
Contributor

alextran1502 commented Jul 30, 2022

Did some performance testing to check if the current backup preparation (code in method startBackupProcess until _backupService.backupAsset is called) takes too much time/CPU/battery to run often (as a potentially frequently running background service). Experiment environment: release build of immich, ~6000 photos on the device and on the server, triggering a start of the background service by taking a new picture.

total  overhead  getBackupInfo  assetsWillBeBackup
 2561      2473           1469                 837
 2357      2253            882                1248
 2829      2764           1258                1359

All measurements are in milliseconds (3 runs in total). total is the time from the line in Kotlin BackupWorker.startWork until last line in Kotlin BackupWorker.stopEngine. overhead is the entire time spent in _onPhotosChanged (opening Hive boxes, instantiating riverpod services, calling backupProvider.resumeBackup until _backupService.backupAsset is called (where the actual backup is performed). getBackupInfo is the time taken by that function in backupProvider (note: it's actually called twice in parallel, once by instatiating the backupProvider, once by calling startBackupProcess, thus multiply its value by 2 to get CPU time usage). assetsWillBeBackup refers to time taken in startBackupProcess where the set of assets to upload is created (by removing assets already present on the server).

Using the existing backup logic burns (when already optimizing the duplicate call to getBackupInfo) roughly 2.5 seconds worth of CPU time (and battery!). This heavy cost is paid every time the background service is triggered to upload any number of new photos (usually just 1). Thus, I'll definitely look into ways to significantly reduce these 2.5 seconds, otherwise using the background service eats up the users phone battery too quickly. Most likely I'll use the list of changed assets given by Android when calling the worker and directly calling _backupService.backupAsset with a set of new assets (after verifying these are not already backed up to the server).

This is a very good assessment and testing, thank you. Another direction I think we can try to mitigate the overhead of using our traditional backup method is to check if the photos/videos are already on the server by directly asking the server. We currently have that API/Endpoint (/asset/check) or using the SDK

image

With the minimal assets will be back up each time. I think most cases will be under 10, and I think this won't sweat the server too much even if it has 100,000 records in the database since those two parameters are indexes.

Edited: Those two are not indexes. They are unique columns, we can also make them indexes.

@fyfrey
Copy link
Contributor Author

fyfrey commented Jul 30, 2022

This is a very good assessment and testing, thank you. Another direction I think we can try to mitigate the overhead of using our traditional backup method is to check if the photos/videos are already on the server by directly asking the server. We currently have that API/Endpoint (/asset/check) or using the SDK

image

With the minimal assets will be back up each time. I think most cases will be under 10, and I think this won't sweat the server too much even if it has 100,000 records in the database since those two parameters are indexes

Edited: Those two are not indexes. They are unique columns, we can also make them indexes.

Good idea! I'll absolutely use that API to check for duplicates. Pairs extremely well with the short list of changed files the background service receives. This should reduce the assetsWillBeBackup CPU time considerably, leaving mostly getBackupInfo. Luckily, this is easy to replace in the background service :-)

Might even work for the currently used full check (i.e. uploading 6000 photo IDs to server and let it check if they are already there instead of downloading 6000 IDs and checking on device). Thus, the server does the comparison by looping over all ids.. or one could maybe write a single, optimized SQL query.

@alextran1502
Copy link
Contributor

Might even work for the currently used full check (i.e. uploading 6000 photo IDs to server and let it check if they are already there instead of downloading 6000 IDs and checking on device). Thus, the server does the comparison by looping over all ids.. or one could maybe write a single, optimized SQL query.

I like this idea, any return IDs mean that they are good to go.

@fyfrey fyfrey force-pushed the feature/backgroundBackup branch from 45a40e2 to cd6c8fd Compare August 10, 2022 13:48
@fyfrey fyfrey requested a review from alextran1502 August 10, 2022 15:12
@fyfrey fyfrey marked this pull request as ready for review August 10, 2022 15:16
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
setForegroundAsync(createForegroundInfo())
Copy link
Contributor

@matthinc matthinc Aug 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a new asset is added to an excluded album, onPhotosChanged is never called and a blank notification is shown for a few seconds. Maybe you could add default content (something like "Checking for new assets...").

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea!
On my virtual devices, Android shows the foreground notification only after ~10 seconds, so this should not happen usually. However, some older devices might show it sooner.
Note: onPhotosChanged is called regardless of which albums were changed

@matthinc
Copy link
Contributor

I used this for some hours now and it seems to work very well. There could always be situations where some power saving logic kicks in (my phone is very strict with that) but we have to see how this works on the long term.
The code looks super well written and I learned a lot about those Android workers 😄

My suggestion would be to merge this (it does not seem to break anything existing) and do potential optimizations in a second PR once we received some user feedback about this. This PR in its current state already is a huge improvement IMO.

LGTM!

@zackpollard
Copy link
Contributor

Hey,

Just catching up on this PR and the work you've done is great! I concur with @matthinc that we could merge this and then make performance improvements in the second iteration. I do have some suggestions though that might be worth taking onboard before we do so:

  • We should provide a way for users to only allow background uploading when the phone is on WiFi and ideally also a toggle for only allowing it when the phone is charging.
  • Background uploading should always upload everything that hasn't yet been uploaded, the new method sounds like it will only upload files that have just changed, so if we implemented toggles like these, files would be missed.
  • It might make sense to avoid having both a foreground and background way of processing uploads. Personally I would approach this by having the foreground uploader just invoke the background process with a list of assets that need backing up, then if the user closes the app after clicking backup, the process continues. It would also likely simplify the logic as you don't need handoff between the foreground and background processes.
  • On performance improvements, I believe we can improve this by having a better local state. We can store which assets the phone considers to have uploaded so we don't need to check them again. This would also have the benefit that if the asset is manually deleted from the server, the phone doesn't just put it straight back. This would vastly improve the performance of the check with the server to see which assets need to be uploaded as it would be checking a much smaller list of assets each time.

Let me know what you think about all of this and feel free to reply here or ping me on the discord if you want to discuss further.

Cheers for the work on this PR!

@fyfrey
Copy link
Contributor Author

fyfrey commented Aug 11, 2022

I used this for some hours now and it seems to work very well. There could always be situations where some power saving logic kicks in (my phone is very strict with that) but we have to see how this works on the long term. The code looks super well written and I learned a lot about those Android workers 😄

Thanks! And you are right about the power saving logic. Android (in somewhat recent versions) is very strict about background battery usage. Thus, the system decides when to run the backup worker (usually scheduled together with other tasks to only wake up the CPU from sleep once and use battery expensive network in one batch).

My suggestion would be to merge this (it does not seem to break anything existing) and do potential optimizations in a second PR once we received some user feedback about this. This PR in its current state already is a huge improvement IMO.

LGTM!

Great! I've still got some TODOs in my code that I want to resolve beforehand (mainly internationalization)

@fyfrey
Copy link
Contributor Author

fyfrey commented Aug 11, 2022

1. We should provide a way for users to only allow background uploading when the phone is on WiFi and ideally also a toggle for only allowing it when the phone is charging.

I agree. Currently, WiFi is always required, but not charging. It's already configurable in Dart code. I could add toggles for these two settings in the backup screen as well, likely below the button to start/stop the background service.

2. Background uploading should always upload everything that hasn't yet been uploaded, the new method sounds like it will only upload files that have just changed, so if we implemented toggles like these, files would be missed.

It uploads almost everything that has not yet been uploaded. There is one exception: The user could move a photo from an excluded album to an included album. This would currently not be detected. I'd rather solve this issue in a follow-up PR.

3. It might make sense to avoid having both a foreground and background way of processing uploads. Personally I would approach this by having the foreground uploader just invoke the background process with a list of assets that need backing up, then if the user closes the app after clicking backup, the process continues. It would also likely simplify the logic as you don't need handoff between the foreground and background processes.

Agreed. I'd do that in another PR as well once the background service has been tested to work well in general.

4. On performance improvements, I believe we can improve this by having a better local state. We can store which assets the phone considers to have uploaded so we don't need to check them again. This would also have the benefit that if the asset is manually deleted from the server, the phone doesn't just put it straight back. This would vastly improve the performance of the check with the server to see which assets need to be uploaded as it would be checking a much smaller list of assets each time.

True, I would also advocate for a local client state to keep track which assets have already been backed up. It also allows to mitigate the issue 2. However, as it would severely change the current foreground backup, I'd rather tackle this in a separate PR.

Let me know what you think about all of this and feel free to reply here or ping me on the discord if you want to discuss further.

Cheers for the work on this PR!

Thanks! I'll reach out to you on discord to discuss some of the details

@alextran1502
Copy link
Contributor

alextran1502 commented Aug 11, 2022

Thanks, @zoodyy, for an amazing PR. I just did a quick test, and there are some bugs that I want to put the steps to reproduce here. Tested on Samsung S9 release version

Case 1 - App's states aren't updating

  1. Start the app
  2. Enable background backup
  3. Switch to the camera app, take a few photos
  4. Immediately switch back to Immich
  5. Background service is notified to be running, but there is an error in opening the HiveBox, it seems.
  6. [ERROR] The state is in deadlock until explicitly closing the app.

Case 2 - Disable background backup not working

  1. Start the app
  2. Enable background backup
  3. Take some photos and make sure the photos are uploaded in the background
  4. Go to Immich
  5. Disable background backup
  6. Take some more photos
  7. [ERROR] The workers are still uploading the new photos.

I would like to sort out these issues before merging the PR

Some info about the bug

Here is the log when I toggle the enable background backup button

Turning on
image

Turning off
image

The methods in question are disableBatteryOptimizations and stopService. My suggestion is have try-catch block in these methods and handle the error accordingly

),
);
}

///
/// Invoke backup process
///
void startBackupProcess() async {
Future<void> startBackupProcess() async {
assert(state.backupProgress == BackUpProgressEnum.idle);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this crash the app?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in debug mode, yes.
In release mode, no as all asserts are disabled

}

return;
}

Future<void> resumeBackup() async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this is called from the external context of the file, let's put in some comments so late comers understand why there are two resumeBackup functions here

@@ -0,0 +1,379 @@
import 'dart:async';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably rename this file to android_background_service I think we will have the same counterpart for iOS as well

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've read about dart/flutter platform specific plugins and usually you use the same dart file/class to communicate with both Android and iOS.

@alextran1502
Copy link
Contributor

alextran1502 commented Aug 17, 2022

@zoodyy I've just done some testing. One of the behaviors that are worth reporting is

  1. Enable auto backup.
  2. Select an album, let's say Screenshot only
  3. Take a photo - This supposes to go to the Recent album
  4. Now select only Recent.
  5. Put the app into the background.
  6. Is the app supposed to perform background backup here? The current behavior is that it doesn't upload that new photo.
  7. If I take a new photo, then it will perform an upload on the last two.

This is not a major issue, this can be treated as expected behavior if it simplifies the code. The user simply needs to take a new photo to re-enable the background backup, and this is something we can include in the background backup info on the WIKI.

Thanks for the amazing work; I know a lot of Android users will be very happy with this feature.

onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
},
onPressed: hasExclusiveAccess
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have this condition?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user must not change the selected albums while the background service is running as both would fight over the Hive box (and a Hive box can only be opened by one thread at a time)

@fyfrey
Copy link
Contributor Author

fyfrey commented Aug 17, 2022

6. Is the app supposed to perform background backup here? The current behavior is that it doesn't upload that new photo.

7. If I take a new photo, then it will perform an upload on the last two.

This is exactly the expected behavior. Everything works as intended: The background backup is only triggered to run whenever assets change (are added/deleted/modified). Since the photo was taken and thus the background service ran before it should backup the photo (only Screenshot album was allowed at that time), the service found that it must not upload that photo. Later, you changed the settings to upload everything (Recent), the next background backup run adds the old photo. However, changing the settings does not directly trigger it, only changing assets (in this case adding a new photo)

In a future PR, we can improve upon this by replacing the foreground upload with the background upload and schedule it to run immediately whenever the user changes which albums to backup etc. In theory, I could easily add this to the current PR, however would rather not force my luck that the foreground and background backup do not interfere :-)

This is not a major issue, this can be treated as expected behavior if it simplifies the code. The user simply needs to take a new photo to re-enable the background backup, and this is something we can include in the background backup info on the WIKI.

Some documentation on the WIKI is certainly handy

Thanks for the amazing work; I know a lot of Android users will be very happy with this feature.

Indeed! Feels a lot better knowing that photos are automatically backed up without needing to regularly start the app to perform the backup

@fyfrey fyfrey force-pushed the feature/backgroundBackup branch from da44cf9 to 0a73749 Compare August 17, 2022 09:14
Fynn Petersen-Frey added 2 commits August 17, 2022 15:07
fix communication erros with Kotlin plugin on start/stop service methods

better error handling for BackgroundService public methods

Add default notification message when service is running
@fyfrey fyfrey force-pushed the feature/backgroundBackup branch from 0a73749 to da14908 Compare August 18, 2022 14:18
@fyfrey
Copy link
Contributor Author

fyfrey commented Aug 18, 2022

This PR should now be ready to be merged 🎉

  • Added options to perform background backup only when on WiFi and/or charging
  • Now using translatable texts

@fyfrey fyfrey force-pushed the feature/backgroundBackup branch from da14908 to 941fc82 Compare August 18, 2022 14:28
@alextran1502 alextran1502 merged commit 33b1410 into immich-app:main Aug 18, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants