From e2166bd15a4ad97c90b81536f5b9276f037bb7bb Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 5 Jul 2021 14:18:39 +0900 Subject: [PATCH] #51 settings: import/export --- .../deckers/thibault/aves/MainActivity.kt | 34 +++++- .../channel/streams/ImageByteStreamHandler.kt | 6 +- .../channel/streams/ImageOpStreamHandler.kt | 2 +- .../streams/StorageAccessStreamHandler.kt | 103 ++++++++++++++-- .../thibault/aves/utils/PermissionManager.kt | 25 +--- lib/l10n/app_en.arb | 5 + lib/l10n/app_ko.arb | 3 + lib/model/actions/entry_actions.dart | 2 +- lib/model/actions/settings_actions.dart | 4 + lib/model/filters/filters.dart | 34 +++--- lib/model/settings/settings.dart | 112 +++++++++++++++++- lib/ref/mime_types.dart | 2 + lib/services/image_file_service.dart | 45 +------ lib/services/output_buffer.dart | 36 ++++++ lib/services/storage_service.dart | 63 +++++++++- lib/theme/icons.dart | 4 +- lib/widgets/debug/settings.dart | 11 +- lib/widgets/settings/settings_page.dart | 65 +++++++++- 18 files changed, 454 insertions(+), 102 deletions(-) create mode 100644 lib/model/actions/settings_actions.dart create mode 100644 lib/services/output_buffer.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index b4eca40aa..2e3f4e14a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -15,11 +15,11 @@ import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.PermissionManager import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ConcurrentHashMap class MainActivity : FlutterActivity() { private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler @@ -92,10 +92,10 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - VOLUME_ACCESS_REQUEST -> { + DOCUMENT_TREE_ACCESS_REQUEST -> { val treeUri = data?.data if (resultCode != RESULT_OK || treeUri == null) { - PermissionManager.onPermissionResult(requestCode, null) + onPermissionResult(requestCode, null) return } @@ -106,7 +106,7 @@ class MainActivity : FlutterActivity() { contentResolver.takePersistableUriPermission(treeUri, takeFlags) // resume pending action - PermissionManager.onPermissionResult(requestCode, treeUri) + onPermissionResult(requestCode, treeUri) } DELETE_PERMISSION_REQUEST -> { // delete permission may be requested on Android 10+ only @@ -114,6 +114,9 @@ class MainActivity : FlutterActivity() { MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) } } + CREATE_FILE_REQUEST, OPEN_FILE_REQUEST -> { + onPermissionResult(requestCode, data?.data) + } } } @@ -189,7 +192,26 @@ class MainActivity : FlutterActivity() { companion object { private val LOG_TAG = LogUtils.createTag() const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" - const val VOLUME_ACCESS_REQUEST = 1 + const val DOCUMENT_TREE_ACCESS_REQUEST = 1 const val DELETE_PERMISSION_REQUEST = 2 + const val CREATE_FILE_REQUEST = 3 + const val OPEN_FILE_REQUEST = 4 + + // permission request code to pending runnable + val pendingResultHandlers = ConcurrentHashMap() + + fun onPermissionResult(requestCode: Int, uri: Uri?) { + Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, uri=$uri") + val handler = pendingResultHandlers.remove(requestCode) ?: return + if (uri != null) { + handler.onGranted(uri) + } else { + handler.onDenied() + } + } } -} \ No newline at end of file +} + +// onGranted: user selected a directory/file (with no guarantee that it matches the requested `path`) +// onDenied: user cancelled +data class PendingResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 2b2f73fc1..dbd1ee080 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -187,12 +187,12 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen companion object { private val LOG_TAG = LogUtils.createTag() - const val CHANNEL = "deckers.thibault/aves/imagebytestream" + const val CHANNEL = "deckers.thibault/aves/image_byte_stream" - const val BUFFER_SIZE = 2 shl 17 // 256kB + private const val BUFFER_SIZE = 2 shl 17 // 256kB // request a fresh image with the highest quality format - val glideOptions = RequestOptions() + private val glideOptions = RequestOptions() .format(DecodeFormat.PREFER_ARGB_8888) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 25f872da4..7170a3105 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -177,6 +177,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments companion object { private val LOG_TAG = LogUtils.createTag() - const val CHANNEL = "deckers.thibault/aves/imageopstream" + const val CHANNEL = "deckers.thibault/aves/image_op_stream" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 649425255..3937e6683 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -1,25 +1,35 @@ package deckers.thibault.aves.channel.streams import android.app.Activity +import android.content.Intent import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log +import deckers.thibault.aves.MainActivity +import deckers.thibault.aves.PendingResultHandler import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess +import deckers.thibault.aves.utils.PermissionManager import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.FileOutputStream // starting activity to give access with the native dialog // breaks the regular `MethodChannel` so we use a stream channel instead class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler { private lateinit var eventSink: EventSink private lateinit var handler: Handler - private var path: String? = null + + private var op: String? = null + private lateinit var args: Map<*, *> init { if (arguments is Map<*, *>) { - path = arguments["path"] as String? + op = arguments["op"] as String? + args = arguments } } @@ -27,6 +37,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) + when (op) { + "requestVolumeAccess" -> requestVolumeAccess() + "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } + "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } + else -> endOfStream() + } + } + + private fun requestVolumeAccess() { + val path = args["path"] as String? if (path == null) { error("requestVolumeAccess-args", "failed because of missing arguments", null) return @@ -37,12 +57,80 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? return } - requestVolumeAccess(activity, path!!, { success(true) }, { success(false) }) + PermissionManager.requestVolumeAccess(activity, path, { + success(true) + endOfStream() + }, { + success(false) + endOfStream() + }) + } + + private fun createFile() { + val name = args["name"] as String? + val mimeType = args["mimeType"] as String? + val bytes = args["bytes"] as ByteArray? + if (name == null || mimeType == null || bytes == null) { + error("createFile-args", "failed because of missing arguments", null) + return + } + + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + putExtra(Intent.EXTRA_TITLE, name) + } + MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri -> + try { + activity.contentResolver.openOutputStream(uri)?.use { output -> + output as FileOutputStream + // truncate is necessary when overwriting a longer file + output.channel.truncate(0) + output.write(bytes) + } + success(true) + } catch (e: Exception) { + error("createFile-write", "failed to write file at uri=$uri", e.message) + } + endOfStream() + }, { + success(null) + endOfStream() + }) + activity.startActivityForResult(intent, MainActivity.CREATE_FILE_REQUEST) + } + + + private fun openFile() { + val mimeType = args["mimeType"] as String? + if (mimeType == null) { + error("openFile-args", "failed because of missing arguments", null) + return + } + + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + } + MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri -> + activity.contentResolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(BUFFER_SIZE) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + success(buffer.copyOf(len)) + } + endOfStream() + } + }, { + success(ByteArray(0)) + endOfStream() + }) + activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) } override fun onCancel(arguments: Any?) {} - private fun success(result: Boolean) { + private fun success(result: Any?) { handler.post { try { eventSink.success(result) @@ -50,7 +138,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? Log.w(LOG_TAG, "failed to use event sink", e) } } - endOfStream() } @Suppress("SameParameterValue") @@ -62,6 +149,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? Log.w(LOG_TAG, "failed to use event sink", e) } } + endOfStream() } private fun endOfStream() { @@ -76,6 +164,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? companion object { private val LOG_TAG = LogUtils.createTag() - const val CHANNEL = "deckers.thibault/aves/storageaccessstream" + const val CHANNEL = "deckers.thibault/aves/storage_access_stream" + private const val BUFFER_SIZE = 2 shl 17 // 256kB } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 83c9692ab..ae6701cbf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -9,22 +9,19 @@ import android.os.Environment import android.os.storage.StorageManager import android.util.Log import androidx.annotation.RequiresApi -import deckers.thibault.aves.MainActivity.Companion.VOLUME_ACCESS_REQUEST +import deckers.thibault.aves.MainActivity +import deckers.thibault.aves.PendingResultHandler import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File import java.util.* -import java.util.concurrent.ConcurrentHashMap import kotlin.collections.ArrayList object PermissionManager { private val LOG_TAG = LogUtils.createTag() - // permission request code to pending runnable - private val pendingPermissionMap = ConcurrentHashMap() - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) { - Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path") + fun requestVolumeAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { + Log.i(LOG_TAG, "request user to select and grant access permission to path=$path") var intent: Intent? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -38,20 +35,14 @@ object PermissionManager { } if (intent.resolveActivity(activity.packageManager) != null) { - pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied) - activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null) + MainActivity.pendingResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingResultHandler(path, onGranted, onDenied) + activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) } else { Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") onDenied() } } - fun onPermissionResult(requestCode: Int, treeUri: Uri?) { - Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, treeUri=$treeUri") - val handler = pendingPermissionMap.remove(requestCode) ?: return - (if (treeUri != null) handler.onGranted else handler.onDenied)() - } - fun getGrantedDirForPath(context: Context, anyPath: String): String? { return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } } @@ -167,8 +158,4 @@ object PermissionManager { } return accessibleDirs } - - // onGranted: user gave access to a directory, with no guarantee that it matches the specified `path` - // onDenied: user cancelled - internal data class PendingPermissionHandler(val path: String, val onGranted: () -> Unit, val onDenied: () -> Unit) } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cdd3f753e..9d549bcea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -538,6 +538,11 @@ "settingsSystemDefault": "System", "@settingsSystemDefault": {}, + "settingsActionExport": "Export", + "@settingsActionExport": {}, + "settingsActionImport": "Import", + "@settingsActionImport": {}, + "settingsSectionNavigation": "Navigation", "@settingsSectionNavigation": {}, "settingsHome": "Home", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 836c8561a..b32bbba8f 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -250,6 +250,9 @@ "settingsPageTitle": "설정", "settingsSystemDefault": "시스템", + "settingsActionExport": "내보내기", + "settingsActionImport": "가져오기", + "settingsSectionNavigation": "탐색", "settingsHome": "홈", "settingsKeepScreenOnTile": "화면 자동 꺼짐 방지", diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 5c8923ce1..e24dfbec4 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -113,7 +113,7 @@ extension ExtraEntryAction on EntryAction { case EntryAction.delete: return AIcons.delete; case EntryAction.export: - return AIcons.export; + return AIcons.saveAs; case EntryAction.info: return AIcons.info; case EntryAction.rename: diff --git a/lib/model/actions/settings_actions.dart b/lib/model/actions/settings_actions.dart new file mode 100644 index 000000000..77e5a557a --- /dev/null +++ b/lib/model/actions/settings_actions.dart @@ -0,0 +1,4 @@ +enum SettingsAction { + export, + import, +} diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index d0da56c81..3c141385d 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -26,22 +26,24 @@ abstract class CollectionFilter implements Comparable { static CollectionFilter? fromJson(String jsonString) { final jsonMap = jsonDecode(jsonString); - final type = jsonMap['type']; - switch (type) { - case AlbumFilter.type: - return AlbumFilter.fromMap(jsonMap); - case FavouriteFilter.type: - return FavouriteFilter.instance; - case LocationFilter.type: - return LocationFilter.fromMap(jsonMap); - case TypeFilter.type: - return TypeFilter.fromMap(jsonMap); - case MimeFilter.type: - return MimeFilter.fromMap(jsonMap); - case QueryFilter.type: - return QueryFilter.fromMap(jsonMap); - case TagFilter.type: - return TagFilter.fromMap(jsonMap); + if (jsonMap is Map) { + final type = jsonMap['type']; + switch (type) { + case AlbumFilter.type: + return AlbumFilter.fromMap(jsonMap); + case FavouriteFilter.type: + return FavouriteFilter.instance; + case LocationFilter.type: + return LocationFilter.fromMap(jsonMap); + case TypeFilter.type: + return TypeFilter.fromMap(jsonMap); + case MimeFilter.type: + return MimeFilter.fromMap(jsonMap); + case QueryFilter.type: + return QueryFilter.fromMap(jsonMap); + case TagFilter.type: + return TagFilter.fromMap(jsonMap); + } } debugPrint('failed to parse filter from json=$jsonString'); return null; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 3bc70e6f9..3f95c206d 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/filters/filters.dart'; @@ -25,6 +27,14 @@ class Settings extends ChangeNotifier { _platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?)); } + static const Set internalKeys = { + hasAcceptedTermsKey, + catalogTimeZoneKey, + videoShowRawTimedTextKey, + searchHistoryKey, + lastVersionCheckDateKey, + }; + // app static const hasAcceptedTermsKey = 'has_accepted_terms'; static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled'; @@ -109,8 +119,12 @@ class Settings extends ChangeNotifier { await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled); } - Future reset() { - return _prefs!.clear(); + Future reset({required bool includeInternalKeys}) async { + if (includeInternalKeys) { + await _prefs!.clear(); + } else { + await Future.forEach(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove); + } } // app @@ -398,4 +412,98 @@ class Settings extends ChangeNotifier { bool _isRotationLocked = false; bool get isRotationLocked => _isRotationLocked; + + // import/export + + String toJson() => jsonEncode(Map.fromEntries( + _prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))), + )); + + Future fromJson(String jsonString) async { + final jsonMap = jsonDecode(jsonString); + if (jsonMap is Map) { + // clear to restore defaults + await reset(includeInternalKeys: false); + + // apply user modifications + jsonMap.forEach((key, value) { + if (key.startsWith(tileExtentPrefixKey)) { + if (value is double) { + _prefs!.setDouble(key, value); + } else { + debugPrint('failed to import key=$key, value=$value is not a double'); + } + } else { + switch (key) { + case subtitleTextColorKey: + case subtitleBackgroundColorKey: + if (value is int) { + _prefs!.setInt(key, value); + } else { + debugPrint('failed to import key=$key, value=$value is not an int'); + } + break; + case subtitleFontSizeKey: + case infoMapZoomKey: + if (value is double) { + _prefs!.setDouble(key, value); + } else { + debugPrint('failed to import key=$key, value=$value is not a double'); + } + break; + case isCrashlyticsEnabledKey: + case mustBackTwiceToExitKey: + case showThumbnailLocationKey: + case showThumbnailRawKey: + case showThumbnailVideoDurationKey: + case showOverlayMinimapKey: + case showOverlayInfoKey: + case showOverlayShootingDetailsKey: + case enableVideoHardwareAccelerationKey: + case enableVideoAutoPlayKey: + case subtitleShowOutlineKey: + case saveSearchHistoryKey: + if (value is bool) { + _prefs!.setBool(key, value); + } else { + debugPrint('failed to import key=$key, value=$value is not a bool'); + } + break; + case localeKey: + case keepScreenOnKey: + case homePageKey: + case collectionGroupFactorKey: + case collectionSortFactorKey: + case albumGroupFactorKey: + case albumSortFactorKey: + case countrySortFactorKey: + case tagSortFactorKey: + case videoLoopModeKey: + case subtitleTextAlignmentKey: + case infoMapStyleKey: + case coordinateFormatKey: + case rasterBackgroundKey: + case vectorBackgroundKey: + if (value is String) { + _prefs!.setString(key, value); + } else { + debugPrint('failed to import key=$key, value=$value is not a string'); + } + break; + case pinnedFiltersKey: + case hiddenFiltersKey: + case viewerQuickActionsKey: + case videoQuickActionsKey: + if (value is List) { + _prefs!.setStringList(key, value.cast()); + } else { + debugPrint('failed to import key=$key, value=$value is not a list'); + } + break; + } + } + }); + notifyListeners(); + } + } } diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index aa14e05bd..e35ed2ee5 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -44,6 +44,8 @@ class MimeTypes { static const mp2t = 'video/mp2t'; // .m2ts static const mp4 = 'video/mp4'; + static const json = 'application/json'; + // groups // formats that support transparency diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 8b1f24362..b5461c602 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:aves/model/entry.dart'; import 'package:aves/services/image_op_events.dart'; +import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -96,8 +96,8 @@ abstract class ImageFileService { class PlatformImageFileService implements ImageFileService { static const platform = MethodChannel('deckers.thibault/aves/image'); - static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); - static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); + static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/image_byte_stream'); + static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/image_op_stream'); static const double thumbnailDefaultSize = 64.0; static Map _toPlatformEntryMap(AvesEntry entry) { @@ -157,7 +157,7 @@ class PlatformImageFileService implements ImageFileService { }) { try { final completer = Completer.sync(); - final sink = _OutputBuffer(); + final sink = OutputBuffer(); var bytesReceived = 0; _byteStreamChannel.receiveBroadcastStream({ 'uri': uri, @@ -405,40 +405,3 @@ class PlatformImageFileService implements ImageFileService { return {}; } } - -// cf flutter/foundation `consolidateHttpClientResponseBytes` -typedef BytesReceivedCallback = void Function(int cumulative, int? total); - -// cf flutter/foundation `consolidateHttpClientResponseBytes` -class _OutputBuffer extends ByteConversionSinkBase { - List>? _chunks = >[]; - int _contentLength = 0; - Uint8List? _bytes; - - @override - void add(List chunk) { - assert(_bytes == null); - _chunks!.add(chunk); - _contentLength += chunk.length; - } - - @override - void close() { - if (_bytes != null) { - // We've already been closed; this is a no-op - return; - } - _bytes = Uint8List(_contentLength); - var offset = 0; - for (final chunk in _chunks!) { - _bytes!.setRange(offset, offset + chunk.length, chunk); - offset += chunk.length; - } - _chunks = null; - } - - Uint8List get bytes { - assert(_bytes != null); - return _bytes!; - } -} diff --git a/lib/services/output_buffer.dart b/lib/services/output_buffer.dart new file mode 100644 index 000000000..7d7088d3a --- /dev/null +++ b/lib/services/output_buffer.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +// cf flutter/foundation `consolidateHttpClientResponseBytes` +class OutputBuffer extends ByteConversionSinkBase { + List>? _chunks = >[]; + int _contentLength = 0; + Uint8List? _bytes; + + @override + void add(List chunk) { + assert(_bytes == null); + _chunks!.add(chunk); + _contentLength += chunk.length; + } + + @override + void close() { + if (_bytes != null) { + // We've already been closed; this is a no-op + return; + } + _bytes = Uint8List(_contentLength); + var offset = 0; + for (final chunk in _chunks!) { + _bytes!.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + _chunks = null; + } + + Uint8List get bytes { + assert(_bytes != null); + return _bytes!; + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index c61de7c1b..107a89108 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:typed_data'; +import 'package:aves/services/output_buffer.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -26,11 +28,16 @@ abstract class StorageService { // returns media URI Future scanFile(String path, String mimeType); + + // return whether operation succeeded (`null` if user cancelled) + Future createFile(String name, String mimeType, Uint8List bytes); + + Future openFile(String mimeType); } class PlatformStorageService implements StorageService { static const platform = MethodChannel('deckers.thibault/aves/storage'); - static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream'); + static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storage_access_stream'); @override Future> getStorageVolumes() async { @@ -113,9 +120,10 @@ class PlatformStorageService implements StorageService { try { final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ + 'op': 'requestVolumeAccess', 'path': volumePath, }).listen( - (data) => completer.complete(data as bool?), + (data) => completer.complete(data as bool), onError: completer.completeError, onDone: () { if (!completer.isCompleted) completer.complete(false); @@ -158,4 +166,55 @@ class PlatformStorageService implements StorageService { } return null; } + + @override + Future createFile(String name, String mimeType, Uint8List bytes) async { + try { + final completer = Completer(); + storageAccessChannel.receiveBroadcastStream({ + 'op': 'createFile', + 'name': name, + 'mimeType': mimeType, + 'bytes': bytes, + }).listen( + (data) => completer.complete(data as bool?), + onError: completer.completeError, + onDone: () { + if (!completer.isCompleted) completer.complete(false); + }, + cancelOnError: true, + ); + return completer.future; + } on PlatformException catch (e) { + debugPrint('createFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return false; + } + + @override + Future openFile(String mimeType) async { + try { + final completer = Completer.sync(); + final sink = OutputBuffer(); + storageAccessChannel.receiveBroadcastStream({ + 'op': 'openFile', + 'mimeType': mimeType, + }).listen( + (data) { + final chunk = data as Uint8List; + sink.add(chunk); + }, + onError: completer.completeError, + onDone: () { + sink.close(); + completer.complete(sink.bytes); + }, + cancelOnError: true, + ); + return completer.future; + } on PlatformException catch (e) { + debugPrint('openFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return Uint8List(0); + } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index f952726b7..cb9e3ad97 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -39,13 +39,14 @@ class AIcons { static const IconData createAlbum = Icons.add_circle_outline; static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; - static const IconData export = Icons.save_alt_outlined; + static const IconData export = MdiIcons.fileExportOutline; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData group = Icons.group_work_outlined; static const IconData hide = Icons.visibility_off_outlined; + static const IconData import = MdiIcons.fileImportOutline; static const IconData info = Icons.info_outlined; static const IconData layers = Icons.layers_outlined; static const IconData openOutside = Icons.open_in_new_outlined; @@ -57,6 +58,7 @@ class AIcons { static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateScreen = Icons.screen_rotation_outlined; + static const IconData saveAs = Icons.save_alt_outlined; static const IconData search = Icons.search_outlined; static const IconData select = Icons.select_all_outlined; static const IconData setCover = MdiIcons.imageEditOutline; diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index 7281cb111..e61a7aa1b 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -22,8 +22,15 @@ class DebugSettingsSection extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: ElevatedButton( - onPressed: () => settings.reset(), - child: const Text('Reset'), + onPressed: () => settings.reset(includeInternalKeys: true), + child: const Text('Reset (all store)'), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: ElevatedButton( + onPressed: () => settings.reset(includeInternalKeys: false), + child: const Text('Reset (user preferences)'), ), ), SwitchListTile( diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index aef10de24..6ca13f610 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -1,4 +1,14 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:aves/model/actions/settings_actions.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/language/language.dart'; @@ -8,7 +18,9 @@ import 'package:aves/widgets/settings/thumbnails.dart'; import 'package:aves/widgets/settings/video/video.dart'; import 'package:aves/widgets/settings/viewer/viewer.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:intl/intl.dart'; class SettingsPage extends StatefulWidget { static const routeName = '/settings'; @@ -19,7 +31,7 @@ class SettingsPage extends StatefulWidget { _SettingsPageState createState() => _SettingsPageState(); } -class _SettingsPageState extends State { +class _SettingsPageState extends State with FeedbackMixin { final ValueNotifier _expandedNotifier = ValueNotifier(null); @override @@ -29,6 +41,26 @@ class _SettingsPageState extends State { child: Scaffold( appBar: AppBar( title: Text(context.l10n.settingsPageTitle), + actions: [ + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: SettingsAction.export, + child: MenuRow(text: context.l10n.settingsActionExport, icon: AIcons.export), + ), + PopupMenuItem( + value: SettingsAction.import, + child: MenuRow(text: context.l10n.settingsActionImport, icon: AIcons.import), + ), + ]; + }, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(action)); + }, + ), + ], ), body: Theme( data: theme.copyWith( @@ -66,4 +98,35 @@ class _SettingsPageState extends State { ), ); } + + void _onActionSelected(SettingsAction action) async { + switch (action) { + case SettingsAction.export: + final success = await storageService.createFile( + 'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json', + MimeTypes.json, + Uint8List.fromList(utf8.encode(settings.toJson())), + ); + if (success != null) { + if (success) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } + } + break; + case SettingsAction.import: + final bytes = await storageService.openFile(MimeTypes.json); + if (bytes.isNotEmpty) { + try { + await settings.fromJson(utf8.decode(bytes)); + showFeedback(context, context.l10n.genericSuccessFeedback); + } catch (error) { + debugPrint('failed to import settings, error=$error'); + showFeedback(context, context.l10n.genericFailureFeedback); + } + } + break; + } + } }