android 15 / api 35, predictive back

This commit is contained in:
Thibault Deckers 2024-07-13 01:32:30 +02:00
parent 0cb139b41a
commit 3d424eb82b
26 changed files with 224 additions and 218 deletions

View file

@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
### Added
- predictive back support (inter-app)
### Changed
- target Android 15 (API 35)
## <a id="v1.11.5"></a>[v1.11.5] - 2024-07-11 ## <a id="v1.11.5"></a>[v1.11.5] - 2024-07-11
### Added ### Added

View file

@ -44,7 +44,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
namespace 'deckers.thibault.aves' namespace 'deckers.thibault.aves'
compileSdk 34 compileSdk 35
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp // cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
ndkVersion '26.1.10909125' ndkVersion '26.1.10909125'
@ -66,7 +66,7 @@ android {
defaultConfig { defaultConfig {
applicationId packageName applicationId packageName
minSdk flutter.minSdkVersion minSdk flutter.minSdkVersion
targetSdk 34 targetSdk 35
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"] manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]

View file

@ -14,10 +14,6 @@
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<!--
TODO TLAD [Android 14 (API 34)] request/handle READ_MEDIA_VISUAL_USER_SELECTED permission
cf https://developer.android.com/about/versions/14/changes/partial-photo-video-access
-->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
@ -35,10 +31,13 @@
<!-- to access media with original metadata with scoped storage (API >=29) --> <!-- to access media with original metadata with scoped storage (API >=29) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- to provide a foreground service type, as required by Android 14 (API 34) --> <!-- to provide a foreground service type, as required from Android 14 (API 34) -->
<!-- TODO TLAD [Android 15 (API 35)] use `FOREGROUND_SERVICE_MEDIA_PROCESSING` -->
<uses-permission <uses-permission
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
android:maxSdkVersion="34"
tools:ignore="SystemPermissionTypo" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING"
tools:ignore="SystemPermissionTypo" /> tools:ignore="SystemPermissionTypo" />
<!-- TODO TLAD still needed to fetch map tiles / reverse geocoding / else ? check in release mode --> <!-- TODO TLAD still needed to fetch map tiles / reverse geocoding / else ? check in release mode -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@ -103,17 +102,12 @@
</intent> </intent>
</queries> </queries>
<!--
as of Flutter v3.16.0, predictive back gesture does not work
as expected when extending `FlutterFragmentActivity`
so we disable `enableOnBackInvokedCallback`
-->
<application <application
android:allowBackup="true" android:allowBackup="true"
android:appCategory="image" android:appCategory="image"
android:banner="@drawable/banner" android:banner="@drawable/banner"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="false" android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/full_backup_content" android:fullBackupContent="@xml/full_backup_content"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -261,11 +255,14 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- anonymous service for analysis worker is specified here to provide service type --> <!--
<!-- TODO TLAD [Android 15 (API 35)] use `mediaProcessing` --> anonymous service for analysis worker is specified here to provide service type:
- `dataSync` for Android 14 (API 34)
- `mediaProcessing` from Android 15 (API 35)
-->
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync|mediaProcessing"
tools:node="merge" /> tools:node="merge" />
<service <service

View file

@ -179,13 +179,12 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
.setContentIntent(openAppIntent) .setContentIntent(openAppIntent)
.addAction(stopAction) .addAction(stopAction)
.build() .build()
return if (Build.VERSION.SDK_INT >= 34) { return if (Build.VERSION.SDK_INT == 34) {
// from Android 14 (API 34), foreground service type is mandatory // from Android 14 (API 34), foreground service type is mandatory for long-running workers:
// despite the sample code omitting it at:
// https://developer.android.com/guide/background/persistent/how-to/long-running // https://developer.android.com/guide/background/persistent/how-to/long-running
// TODO TLAD [Android 15 (API 35)] use `FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC } else if (Build.VERSION.SDK_INT >= 35) {
ForegroundInfo(NOTIFICATION_ID, notification, type) ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
} else { } else {
ForegroundInfo(NOTIFICATION_ID, notification) ForegroundInfo(NOTIFICATION_ID, notification)
} }

View file

@ -42,7 +42,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId) val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
val pendingResult = goAsync() val pendingResult = goAsync()
defaultScope.launch() { defaultScope.launch {
val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false) val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps) updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps)

View file

@ -54,7 +54,7 @@ import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -66,7 +66,7 @@ import kotlinx.coroutines.launch
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
open class MainActivity : FlutterFragmentActivity() { open class MainActivity : FlutterActivity() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import androidx.activity.ComponentActivity
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
@ -10,6 +9,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import deckers.thibault.aves.AnalysisWorker import deckers.thibault.aves.AnalysisWorker
import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.FlutterUtils
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -19,7 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class AnalysisHandler(private val activity: ComponentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler { class AnalysisHandler(private val activity: FlutterActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@ -37,10 +37,11 @@ class AnalysisHandler(private val activity: ComponentActivity, private val onAna
return return
} }
activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
.edit() with(preferences.edit()) {
.putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle) putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle)
.apply() apply()
}
result.success(true) result.success(true)
} }

View file

@ -28,10 +28,11 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
return return
} }
context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
.edit() with(preferences.edit()) {
.putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle) putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
.apply() apply()
}
result.success(true) result.success(true)
} }

View file

@ -44,7 +44,8 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
return return
} }
with(getStore().edit()) { val preferences = getStore()
with(preferences.edit()) {
when (value) { when (value) {
is Boolean -> putBoolean(key, value) is Boolean -> putBoolean(key, value)
is Float -> putFloat(key, value) is Float -> putFloat(key, value)

View file

@ -1,10 +1,10 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.app.Activity
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.fragment.app.FragmentActivity
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
@ -21,9 +21,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class ImageOpStreamHandler(private val activity: FragmentActivity, private val arguments: Any?) : EventChannel.StreamHandler { class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var eventSink: EventSink private lateinit var eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler

View file

@ -152,12 +152,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
@RequiresApi(Build.VERSION_CODES.P) @RequiresApi(Build.VERSION_CODES.P)
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply { private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { preferredConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel) // improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
// for wide-gamut and HDR content which does not require alpha blending // for wide-gamut and HDR content which does not require alpha blending
setPreferredConfig(Bitmap.Config.RGBA_1010102) Bitmap.Config.RGBA_1010102
} else { } else {
setPreferredConfig(Bitmap.Config.ARGB_8888) Bitmap.Config.ARGB_8888
} }
} }

View file

@ -29,7 +29,6 @@ import deckers.thibault.aves.metadata.GeoTiffKeys
import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpfReader import deckers.thibault.aves.metadata.metadataextractor.mpf.MpfReader
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream

View file

@ -12,7 +12,6 @@ import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import androidx.fragment.app.FragmentActivity
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -196,7 +195,7 @@ abstract class ImageProvider {
} }
suspend fun convertMultiple( suspend fun convertMultiple(
activity: FragmentActivity, activity: Activity,
imageExportMimeType: String, imageExportMimeType: String,
targetDir: String, targetDir: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
@ -255,7 +254,7 @@ abstract class ImageProvider {
} }
private suspend fun convertSingle( private suspend fun convertSingle(
activity: FragmentActivity, activity: Activity,
sourceEntry: AvesEntry, sourceEntry: AvesEntry,
targetDir: String, targetDir: String,
targetDirDocFile: DocumentFileCompat?, targetDirDocFile: DocumentFileCompat?,
@ -334,7 +333,7 @@ abstract class ImageProvider {
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true) .skipMemoryCache(true)
target = Glide.with(activity) target = Glide.with(activity.applicationContext)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(model) .load(model)
@ -396,7 +395,7 @@ abstract class ImageProvider {
return newFields return newFields
} finally { } finally {
// clearing Glide target should happen after effectively writing the bitmap // clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target) Glide.with(activity.applicationContext).clear(target)
resolution.replacementFile?.delete() resolution.replacementFile?.delete()
} }

View file

@ -5,5 +5,5 @@ import kotlin.math.pow
object MathUtils { object MathUtils {
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble()) fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt() private fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt()
} }

View file

@ -17,8 +17,8 @@ object MimeTypes {
private const val ICO = "image/x-icon" private const val ICO = "image/x-icon"
const val JPEG = "image/jpeg" const val JPEG = "image/jpeg"
const val PNG = "image/png" const val PNG = "image/png"
const val PSD_VND = "image/vnd.adobe.photoshop" private const val PSD_VND = "image/vnd.adobe.photoshop"
const val PSD_X = "image/x-photoshop" private const val PSD_X = "image/x-photoshop"
const val TIFF = "image/tiff" const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp" private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp" const val WEBP = "image/webp"

View file

@ -21,7 +21,7 @@ class AboutTvPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AvesScaffold( return AvesScaffold(
body: AvesPopScope( body: AvesPopScope(
handlers: const [TvNavigationPopHandler.pop], handlers: [tvNavigationPopHandler],
child: Row( child: Row(
children: [ children: [
TvRail( TvRail(

View file

@ -174,7 +174,8 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
// Flutter has various page transition implementations for Android: // Flutter has various page transition implementations for Android:
// - `FadeUpwardsPageTransitionsBuilder` on Oreo / API 27 and below // - `FadeUpwardsPageTransitionsBuilder` on Oreo / API 27 and below
// - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28
// - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.0.0) // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.22.0)
// - `PredictiveBackPageTransitionsBuilder` for Android 15 / API 35 intra-app predictive back
static const defaultPageTransitionsBuilder = FadeUpwardsPageTransitionsBuilder(); static const defaultPageTransitionsBuilder = FadeUpwardsPageTransitionsBuilder();
static final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); static final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
static ScreenBrightness? _screenBrightness; static ScreenBrightness? _screenBrightness;

View file

@ -55,7 +55,6 @@ class _CollectionPageState extends State<CollectionPage> {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
late CollectionLens _collection; late CollectionLens _collection;
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast(); final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
@override @override
void initState() { void initState() {
@ -80,7 +79,6 @@ class _CollectionPageState extends State<CollectionPage> {
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
_collection.dispose(); _collection.dispose();
_doubleBackPopHandler.dispose();
super.dispose(); super.dispose();
} }
@ -98,16 +96,12 @@ class _CollectionPageState extends State<CollectionPage> {
builder: (context) { builder: (context) {
return AvesPopScope( return AvesPopScope(
handlers: [ handlers: [
(context) { APopHandler(
final selection = context.read<Selection<AvesEntry>>(); canPop: (context) => context.select<Selection<AvesEntry>, bool>((v) => !v.isSelecting),
if (selection.isSelecting) { onPopBlocked: (context) => context.read<Selection<AvesEntry>>().browse(),
selection.browse(); ),
return false; tvNavigationPopHandler,
} doubleBackPopHandler,
return true;
},
TvNavigationPopHandler.pop,
_doubleBackPopHandler.pop,
], ],
child: GestureAreaProtectorStack( child: GestureAreaProtectorStack(
child: DirectionalSafeArea( child: DirectionalSafeArea(

View file

@ -1,48 +1,49 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:overlay_support/overlay_support.dart'; import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart';
class DoubleBackPopHandler { final DoubleBackPopHandler doubleBackPopHandler = DoubleBackPopHandler._private();
class DoubleBackPopHandler extends PopHandler {
bool _backOnce = false; bool _backOnce = false;
Timer? _backTimer; Timer? _backTimer;
DoubleBackPopHandler() { DoubleBackPopHandler._private();
if (kFlutterMemoryAllocationsEnabled) {
FlutterMemoryAllocations.instance.dispatchObjectCreated( @override
library: 'aves', bool canPop(BuildContext context) {
className: '$DoubleBackPopHandler', if (context.select<Settings, bool>((s) => !s.mustBackTwiceToExit)) return true;
object: this, if (Navigator.canPop(context)) return true;
); return false;
}
} }
void dispose() { @override
if (kFlutterMemoryAllocationsEnabled) { void onPopBlocked(BuildContext context) {
FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this); if (_backOnce) {
} if (Navigator.canPop(context)) {
_stopBackTimer(); Navigator.maybeOf(context)?.pop();
} } else {
// exit
bool pop(BuildContext context) { reportService.log('Exit by pop');
if (!Navigator.canPop(context) && settings.mustBackTwiceToExit && !_backOnce) { PopExitNotification().dispatch(context);
SystemNavigator.pop();
}
} else {
_backOnce = true; _backOnce = true;
_stopBackTimer(); _backTimer?.cancel();
_backTimer = Timer(ADurations.doubleBackTimerDelay, () => _backOnce = false); _backTimer = Timer(ADurations.doubleBackTimerDelay, () => _backOnce = false);
toast( toast(
context.l10n.doubleBackExitMessage, context.l10n.doubleBackExitMessage,
duration: ADurations.doubleBackTimerDelay, duration: ADurations.doubleBackTimerDelay,
); );
return false;
} }
return true;
}
void _stopBackTimer() {
_backTimer?.cancel();
} }
} }

View file

@ -1,11 +1,9 @@
import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
// as of Flutter v3.3.10, the resolution order of multiple `WillPopScope` is random // this widget combines multiple pop handlers with a guaranteed order
// so this widget combines multiple handlers with a guaranteed order
class AvesPopScope extends StatelessWidget { class AvesPopScope extends StatelessWidget {
final List<bool Function(BuildContext context)> handlers; final List<PopHandler> handlers;
final Widget child; final Widget child;
const AvesPopScope({ const AvesPopScope({
@ -16,21 +14,12 @@ class AvesPopScope extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final blocker = handlers.firstWhereOrNull((v) => !v.canPop(context));
return PopScope( return PopScope(
canPop: false, canPop: blocker == null,
onPopInvoked: (didPop) { onPopInvoked: (didPop) {
if (didPop) return; if (!didPop) {
blocker?.onPopBlocked(context);
final shouldPop = handlers.fold(true, (prev, v) => prev ? v(context) : false);
if (shouldPop) {
if (Navigator.canPop(context)) {
Navigator.maybeOf(context)?.pop();
} else {
// exit
reportService.log('Exit by pop');
PopExitNotification().dispatch(context);
SystemNavigator.pop();
}
} }
}, },
child: child, child: child,
@ -38,5 +27,28 @@ class AvesPopScope extends StatelessWidget {
} }
} }
abstract class PopHandler {
bool canPop(BuildContext context);
void onPopBlocked(BuildContext context);
}
class APopHandler implements PopHandler {
final bool Function(BuildContext context) _canPop;
final void Function(BuildContext context) _onPopBlocked;
APopHandler({
required bool Function(BuildContext context) canPop,
required void Function(BuildContext context) onPopBlocked,
}) : _canPop = canPop,
_onPopBlocked = onPopBlocked;
@override
bool canPop(BuildContext context) => _canPop(context);
@override
void onPopBlocked(BuildContext context) => _onPopBlocked(context);
}
@immutable @immutable
class PopExitNotification extends Notification {} class PopExitNotification extends Notification {}

View file

@ -3,6 +3,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/explorer/explorer_page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
@ -11,18 +12,25 @@ import 'package:aves_model/aves_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality final TvNavigationPopHandler tvNavigationPopHandler = TvNavigationPopHandler._private();
class TvNavigationPopHandler {
static bool pop(BuildContext context) {
if (!settings.useTvLayout || _isHome(context)) {
return true;
}
// address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality
class TvNavigationPopHandler implements PopHandler {
TvNavigationPopHandler._private();
@override
bool canPop(BuildContext context) {
if (context.select<Settings, bool>((s) => !s.useTvLayout)) return true;
if (_isHome(context)) return true;
return false;
}
@override
void onPopBlocked(BuildContext context) {
Navigator.maybeOf(context)?.pushAndRemoveUntil( Navigator.maybeOf(context)?.pushAndRemoveUntil(
_getHomeRoute(), _getHomeRoute(),
(route) => false, (route) => false,
); );
return false;
} }
static bool _isHome(BuildContext context) { static bool _isHome(BuildContext context) {

View file

@ -1,4 +1,3 @@
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
@ -31,7 +30,6 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> { class _SearchPageState extends State<SearchPage> {
final Debouncer _debouncer = Debouncer(delay: ADurations.searchDebounceDelay); final Debouncer _debouncer = Debouncer(delay: ADurations.searchDebounceDelay);
final FocusNode _searchFieldFocusNode = FocusNode(); final FocusNode _searchFieldFocusNode = FocusNode();
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
@override @override
void initState() { void initState() {
@ -55,7 +53,6 @@ class _SearchPageState extends State<SearchPage> {
_unregisterWidget(widget); _unregisterWidget(widget);
widget.animation.removeStatusListener(_onAnimationStatusChanged); widget.animation.removeStatusListener(_onAnimationStatusChanged);
_searchFieldFocusNode.dispose(); _searchFieldFocusNode.dispose();
_doubleBackPopHandler.dispose();
widget.delegate.dispose(); widget.delegate.dispose();
super.dispose(); super.dispose();
} }
@ -151,8 +148,8 @@ class _SearchPageState extends State<SearchPage> {
), ),
body: AvesPopScope( body: AvesPopScope(
handlers: [ handlers: [
TvNavigationPopHandler.pop, tvNavigationPopHandler,
_doubleBackPopHandler.pop, doubleBackPopHandler,
], ],
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),

View file

@ -67,7 +67,7 @@ class AppDebugPage extends StatelessWidget {
], ],
), ),
body: AvesPopScope( body: AvesPopScope(
handlers: const [TvNavigationPopHandler.pop], handlers: [tvNavigationPopHandler],
child: SafeArea( child: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),

View file

@ -43,7 +43,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
final ValueNotifier<VolumeRelativeDirectory> _directory = ValueNotifier(const VolumeRelativeDirectory(volumePath: '', relativeDir: '')); final ValueNotifier<VolumeRelativeDirectory> _directory = ValueNotifier(const VolumeRelativeDirectory(volumePath: '', relativeDir: ''));
final ValueNotifier<List<Directory>> _contents = ValueNotifier([]); final ValueNotifier<List<Directory>> _contents = ValueNotifier([]);
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
Set<StorageVolume> get _volumes => androidFileUtils.storageVolumes; Set<StorageVolume> get _volumes => androidFileUtils.storageVolumes;
@ -78,99 +77,95 @@ class _ExplorerPageState extends State<ExplorerPage> {
..clear(); ..clear();
_directory.dispose(); _directory.dispose();
_contents.dispose(); _contents.dispose();
_doubleBackPopHandler.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AvesPopScope( return ValueListenableBuilder<VolumeRelativeDirectory>(
handlers: [ valueListenable: _directory,
(context) { builder: (context, directory, child) {
if (_directory.value.relativeDir.isNotEmpty) { final atRoot = directory.relativeDir.isEmpty;
final parent = pContext.dirname(_currentDirectoryPath); return AvesPopScope(
_goTo(parent); handlers: [
return false; APopHandler(
} canPop: (context) => atRoot,
return true; onPopBlocked: (context) => _goTo(pContext.dirname(_currentDirectoryPath)),
}, ),
TvNavigationPopHandler.pop, tvNavigationPopHandler,
_doubleBackPopHandler.pop, doubleBackPopHandler,
], ],
child: AvesScaffold( child: AvesScaffold(
drawer: const AppDrawer(), drawer: const AppDrawer(),
body: GestureAreaProtectorStack( body: GestureAreaProtectorStack(
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: ValueListenableBuilder<List<Directory>>( child: ValueListenableBuilder<List<Directory>>(
valueListenable: _contents, valueListenable: _contents,
builder: (context, contents, child) { builder: (context, contents, child) {
final durations = context.watch<DurationsData>(); final durations = context.watch<DurationsData>();
return CustomScrollView( return CustomScrollView(
// workaround to prevent scrolling the app bar away // workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining` // when there is no content and we use `SliverFillRemaining`
physics: contents.isEmpty ? const NeverScrollableScrollPhysics() : null, physics: contents.isEmpty ? const NeverScrollableScrollPhysics() : null,
slivers: [ slivers: [
ExplorerAppBar( ExplorerAppBar(
key: const Key('appbar'), key: const Key('appbar'),
directoryNotifier: _directory, directoryNotifier: _directory,
goTo: _goTo, goTo: _goTo,
), ),
AnimationLimiter( AnimationLimiter(
// animation limiter should not be above the app bar // animation limiter should not be above the app bar
// so that the crumb line can automatically scroll // so that the crumb line can automatically scroll
key: ValueKey(_currentDirectoryPath), key: ValueKey(_currentDirectoryPath),
child: SliverList.builder( child: SliverList.builder(
itemBuilder: (context, index) { itemBuilder: (context, index) {
return AnimationConfiguration.staggeredList( return AnimationConfiguration.staggeredList(
position: index, position: index,
duration: durations.staggeredAnimation, duration: durations.staggeredAnimation,
delay: durations.staggeredAnimationDelay * timeDilation, delay: durations.staggeredAnimationDelay * timeDilation,
child: SlideAnimation( child: SlideAnimation(
verticalOffset: 50.0, verticalOffset: 50.0,
child: FadeInAnimation( child: FadeInAnimation(
child: _buildContentLine(context, contents[index]), child: _buildContentLine(context, contents[index]),
), ),
), ),
); );
}, },
itemCount: contents.length, itemCount: contents.length,
), ),
), ),
contents.isEmpty contents.isEmpty
? SliverFillRemaining( ? SliverFillRemaining(
child: _buildEmptyContent(), child: _buildEmptyContent(),
) )
: const SliverPadding(padding: EdgeInsets.only(bottom: 8)), : const SliverPadding(padding: EdgeInsets.only(bottom: 8)),
], ],
); );
}, },
), ),
), ),
const Divider(height: 0), const Divider(height: 0),
SafeArea( SafeArea(
top: false, top: false,
bottom: true, bottom: true,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: ValueListenableBuilder<VolumeRelativeDirectory>( child: AvesFilterChip(
valueListenable: _directory,
builder: (context, directory, child) {
return AvesFilterChip(
filter: PathFilter(_currentDirectoryPath), filter: PathFilter(_currentDirectoryPath),
maxWidth: double.infinity, maxWidth: double.infinity,
onTap: (filter) => _goToCollectionPage(context, filter), onTap: (filter) => _goToCollectionPage(context, filter),
onLongPress: null, onLongPress: null,
); ),
}, ),
), ),
), ],
), ),
], ),
), ),
), );
), },
); );
} }

View file

@ -191,12 +191,10 @@ class _FilterGrid<T extends CollectionFilter> extends StatefulWidget {
class _FilterGridState<T extends CollectionFilter> extends State<_FilterGrid<T>> { class _FilterGridState<T extends CollectionFilter> extends State<_FilterGrid<T>> {
TileExtentController? _tileExtentController; TileExtentController? _tileExtentController;
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
@override @override
void dispose() { void dispose() {
_tileExtentController?.dispose(); _tileExtentController?.dispose();
_doubleBackPopHandler.dispose();
super.dispose(); super.dispose();
} }
@ -212,16 +210,12 @@ class _FilterGridState<T extends CollectionFilter> extends State<_FilterGrid<T>>
); );
return AvesPopScope( return AvesPopScope(
handlers: [ handlers: [
(context) { APopHandler(
final selection = context.read<Selection<FilterGridItem<T>>>(); canPop: (context) => context.select<Selection<FilterGridItem<T>>, bool>((v) => !v.isSelecting),
if (selection.isSelecting) { onPopBlocked: (context) => context.read<Selection<FilterGridItem<T>>>().browse(),
selection.browse(); ),
return false; tvNavigationPopHandler,
} doubleBackPopHandler,
return true;
},
TvNavigationPopHandler.pop,
_doubleBackPopHandler.pop,
], ],
child: TileExtentControllerProvider( child: TileExtentControllerProvider(
controller: _tileExtentController!, controller: _tileExtentController!,

View file

@ -16,7 +16,7 @@ class SettingsTvPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AvesScaffold( return AvesScaffold(
body: AvesPopScope( body: AvesPopScope(
handlers: const [TvNavigationPopHandler.pop], handlers: [tvNavigationPopHandler],
child: Row( child: Row(
children: [ children: [
TvRail( TvRail(