#51 settings: import/export

This commit is contained in:
Thibault Deckers 2021-07-05 14:18:39 +09:00
parent 3a2e0349e2
commit e2166bd15a
18 changed files with 454 additions and 102 deletions

View file

@ -15,11 +15,11 @@ import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
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 java.util.concurrent.ConcurrentHashMap
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
@ -92,10 +92,10 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) { when (requestCode) {
VOLUME_ACCESS_REQUEST -> { DOCUMENT_TREE_ACCESS_REQUEST -> {
val treeUri = data?.data val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) { if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null) onPermissionResult(requestCode, null)
return return
} }
@ -106,7 +106,7 @@ class MainActivity : FlutterActivity() {
contentResolver.takePersistableUriPermission(treeUri, takeFlags) contentResolver.takePersistableUriPermission(treeUri, takeFlags)
// resume pending action // resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri) onPermissionResult(requestCode, treeUri)
} }
DELETE_PERMISSION_REQUEST -> { DELETE_PERMISSION_REQUEST -> {
// delete permission may be requested on Android 10+ only // delete permission may be requested on Android 10+ only
@ -114,6 +114,9 @@ class MainActivity : FlutterActivity() {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) 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 { companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>() private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" 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 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<Int, PendingResultHandler>()
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()
}
}
} }
} }
// 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)

View file

@ -187,12 +187,12 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
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 // request a fresh image with the highest quality format
val glideOptions = RequestOptions() private val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888) .format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true) .skipMemoryCache(true)

View file

@ -177,6 +177,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/imageopstream" const val CHANNEL = "deckers.thibault/aves/image_op_stream"
} }
} }

View file

@ -1,25 +1,35 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.app.Activity import android.app.Activity
import android.content.Intent
import android.os.Build import android.os.Build
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 deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingResultHandler
import deckers.thibault.aves.utils.LogUtils 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
import io.flutter.plugin.common.EventChannel.EventSink 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 // starting activity to give access with the native dialog
// breaks the regular `MethodChannel` so we use a stream channel instead // breaks the regular `MethodChannel` so we use a stream channel instead
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler { class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink private lateinit var eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
private var path: String? = null
private var op: String? = null
private lateinit var args: Map<*, *>
init { init {
if (arguments is Map<*, *>) { 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 this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) 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) { if (path == null) {
error("requestVolumeAccess-args", "failed because of missing arguments", null) error("requestVolumeAccess-args", "failed because of missing arguments", null)
return return
@ -37,12 +57,80 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
return 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?) {} override fun onCancel(arguments: Any?) {}
private fun success(result: Boolean) { private fun success(result: Any?) {
handler.post { handler.post {
try { try {
eventSink.success(result) 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) Log.w(LOG_TAG, "failed to use event sink", e)
} }
} }
endOfStream()
} }
@Suppress("SameParameterValue") @Suppress("SameParameterValue")
@ -62,6 +149,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
Log.w(LOG_TAG, "failed to use event sink", e) Log.w(LOG_TAG, "failed to use event sink", e)
} }
} }
endOfStream()
} }
private fun endOfStream() { private fun endOfStream() {
@ -76,6 +164,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>() private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>()
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
} }
} }

View file

@ -9,22 +9,19 @@ import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi 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 deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag<PermissionManager>() private val LOG_TAG = LogUtils.createTag<PermissionManager>()
// permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) { 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 volume=$path") Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
var intent: Intent? = null var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -38,20 +35,14 @@ object PermissionManager {
} }
if (intent.resolveActivity(activity.packageManager) != null) { if (intent.resolveActivity(activity.packageManager) != null) {
pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied) MainActivity.pendingResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingResultHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
} else { } else {
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
onDenied() 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? { fun getGrantedDirForPath(context: Context, anyPath: String): String? {
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
} }
@ -167,8 +158,4 @@ object PermissionManager {
} }
return accessibleDirs 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)
} }

View file

@ -538,6 +538,11 @@
"settingsSystemDefault": "System", "settingsSystemDefault": "System",
"@settingsSystemDefault": {}, "@settingsSystemDefault": {},
"settingsActionExport": "Export",
"@settingsActionExport": {},
"settingsActionImport": "Import",
"@settingsActionImport": {},
"settingsSectionNavigation": "Navigation", "settingsSectionNavigation": "Navigation",
"@settingsSectionNavigation": {}, "@settingsSectionNavigation": {},
"settingsHome": "Home", "settingsHome": "Home",

View file

@ -250,6 +250,9 @@
"settingsPageTitle": "설정", "settingsPageTitle": "설정",
"settingsSystemDefault": "시스템", "settingsSystemDefault": "시스템",
"settingsActionExport": "내보내기",
"settingsActionImport": "가져오기",
"settingsSectionNavigation": "탐색", "settingsSectionNavigation": "탐색",
"settingsHome": "홈", "settingsHome": "홈",
"settingsKeepScreenOnTile": "화면 자동 꺼짐 방지", "settingsKeepScreenOnTile": "화면 자동 꺼짐 방지",

View file

@ -113,7 +113,7 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.delete: case EntryAction.delete:
return AIcons.delete; return AIcons.delete;
case EntryAction.export: case EntryAction.export:
return AIcons.export; return AIcons.saveAs;
case EntryAction.info: case EntryAction.info:
return AIcons.info; return AIcons.info;
case EntryAction.rename: case EntryAction.rename:

View file

@ -0,0 +1,4 @@
enum SettingsAction {
export,
import,
}

View file

@ -26,22 +26,24 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
static CollectionFilter? fromJson(String jsonString) { static CollectionFilter? fromJson(String jsonString) {
final jsonMap = jsonDecode(jsonString); final jsonMap = jsonDecode(jsonString);
final type = jsonMap['type']; if (jsonMap is Map<String, dynamic>) {
switch (type) { final type = jsonMap['type'];
case AlbumFilter.type: switch (type) {
return AlbumFilter.fromMap(jsonMap); case AlbumFilter.type:
case FavouriteFilter.type: return AlbumFilter.fromMap(jsonMap);
return FavouriteFilter.instance; case FavouriteFilter.type:
case LocationFilter.type: return FavouriteFilter.instance;
return LocationFilter.fromMap(jsonMap); case LocationFilter.type:
case TypeFilter.type: return LocationFilter.fromMap(jsonMap);
return TypeFilter.fromMap(jsonMap); case TypeFilter.type:
case MimeFilter.type: return TypeFilter.fromMap(jsonMap);
return MimeFilter.fromMap(jsonMap); case MimeFilter.type:
case QueryFilter.type: return MimeFilter.fromMap(jsonMap);
return QueryFilter.fromMap(jsonMap); case QueryFilter.type:
case TagFilter.type: return QueryFilter.fromMap(jsonMap);
return TagFilter.fromMap(jsonMap); case TagFilter.type:
return TagFilter.fromMap(jsonMap);
}
} }
debugPrint('failed to parse filter from json=$jsonString'); debugPrint('failed to parse filter from json=$jsonString');
return null; return null;

View file

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -25,6 +27,14 @@ class Settings extends ChangeNotifier {
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?)); _platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
} }
static const Set<String> internalKeys = {
hasAcceptedTermsKey,
catalogTimeZoneKey,
videoShowRawTimedTextKey,
searchHistoryKey,
lastVersionCheckDateKey,
};
// app // app
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled'; static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
@ -109,8 +119,12 @@ class Settings extends ChangeNotifier {
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled); await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled);
} }
Future<void> reset() { Future<void> reset({required bool includeInternalKeys}) async {
return _prefs!.clear(); if (includeInternalKeys) {
await _prefs!.clear();
} else {
await Future.forEach(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
}
} }
// app // app
@ -398,4 +412,98 @@ class Settings extends ChangeNotifier {
bool _isRotationLocked = false; bool _isRotationLocked = false;
bool get isRotationLocked => _isRotationLocked; bool get isRotationLocked => _isRotationLocked;
// import/export
String toJson() => jsonEncode(Map.fromEntries(
_prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))),
));
Future<void> fromJson(String jsonString) async {
final jsonMap = jsonDecode(jsonString);
if (jsonMap is Map<String, dynamic>) {
// 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<String>());
} else {
debugPrint('failed to import key=$key, value=$value is not a list');
}
break;
}
}
});
notifyListeners();
}
}
} }

View file

@ -44,6 +44,8 @@ class MimeTypes {
static const mp2t = 'video/mp2t'; // .m2ts static const mp2t = 'video/mp2t'; // .m2ts
static const mp4 = 'video/mp4'; static const mp4 = 'video/mp4';
static const json = 'application/json';
// groups // groups
// formats that support transparency // formats that support transparency

View file

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/image_op_events.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:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -96,8 +96,8 @@ abstract class ImageFileService {
class PlatformImageFileService implements ImageFileService { class PlatformImageFileService implements ImageFileService {
static const platform = MethodChannel('deckers.thibault/aves/image'); static const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/image_byte_stream');
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/image_op_stream');
static const double thumbnailDefaultSize = 64.0; static const double thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) { static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
@ -157,7 +157,7 @@ class PlatformImageFileService implements ImageFileService {
}) { }) {
try { try {
final completer = Completer<Uint8List>.sync(); final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer(); final sink = OutputBuffer();
var bytesReceived = 0; var bytesReceived = 0;
_byteStreamChannel.receiveBroadcastStream(<String, dynamic>{ _byteStreamChannel.receiveBroadcastStream(<String, dynamic>{
'uri': uri, 'uri': uri,
@ -405,40 +405,3 @@ class PlatformImageFileService implements ImageFileService {
return {}; return {};
} }
} }
// cf flutter/foundation `consolidateHttpClientResponseBytes`
typedef BytesReceivedCallback = void Function(int cumulative, int? total);
// cf flutter/foundation `consolidateHttpClientResponseBytes`
class _OutputBuffer extends ByteConversionSinkBase {
List<List<int>>? _chunks = <List<int>>[];
int _contentLength = 0;
Uint8List? _bytes;
@override
void add(List<int> 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!;
}
}

View file

@ -0,0 +1,36 @@
import 'dart:convert';
import 'dart:typed_data';
// cf flutter/foundation `consolidateHttpClientResponseBytes`
class OutputBuffer extends ByteConversionSinkBase {
List<List<int>>? _chunks = <List<int>>[];
int _contentLength = 0;
Uint8List? _bytes;
@override
void add(List<int> 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!;
}
}

View file

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:aves/services/output_buffer.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -26,11 +28,16 @@ abstract class StorageService {
// returns media URI // returns media URI
Future<Uri?> scanFile(String path, String mimeType); Future<Uri?> scanFile(String path, String mimeType);
// return whether operation succeeded (`null` if user cancelled)
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType);
} }
class PlatformStorageService implements StorageService { class PlatformStorageService implements StorageService {
static const platform = MethodChannel('deckers.thibault/aves/storage'); 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 @override
Future<Set<StorageVolume>> getStorageVolumes() async { Future<Set<StorageVolume>> getStorageVolumes() async {
@ -113,9 +120,10 @@ class PlatformStorageService implements StorageService {
try { try {
final completer = Completer<bool>(); final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{ storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'requestVolumeAccess',
'path': volumePath, 'path': volumePath,
}).listen( }).listen(
(data) => completer.complete(data as bool?), (data) => completer.complete(data as bool),
onError: completer.completeError, onError: completer.completeError,
onDone: () { onDone: () {
if (!completer.isCompleted) completer.complete(false); if (!completer.isCompleted) completer.complete(false);
@ -158,4 +166,55 @@ class PlatformStorageService implements StorageService {
} }
return null; return null;
} }
@override
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
try {
final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'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<Uint8List> openFile(String mimeType) async {
try {
final completer = Completer<Uint8List>.sync();
final sink = OutputBuffer();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'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);
}
} }

View file

@ -39,13 +39,14 @@ class AIcons {
static const IconData createAlbum = Icons.add_circle_outline; static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_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 flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
static const IconData goUp = Icons.arrow_upward_outlined; static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = Icons.group_work_outlined; static const IconData group = Icons.group_work_outlined;
static const IconData hide = Icons.visibility_off_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 info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined; static const IconData layers = Icons.layers_outlined;
static const IconData openOutside = Icons.open_in_new_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 rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_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 search = Icons.search_outlined;
static const IconData select = Icons.select_all_outlined; static const IconData select = Icons.select_all_outlined;
static const IconData setCover = MdiIcons.imageEditOutline; static const IconData setCover = MdiIcons.imageEditOutline;

View file

@ -22,8 +22,15 @@ class DebugSettingsSection extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: ElevatedButton( child: ElevatedButton(
onPressed: () => settings.reset(), onPressed: () => settings.reset(includeInternalKeys: true),
child: const Text('Reset'), 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( SwitchListTile(

View file

@ -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/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/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/language/language.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/video/video.dart';
import 'package:aves/widgets/settings/viewer/viewer.dart'; import 'package:aves/widgets/settings/viewer/viewer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:intl/intl.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
static const routeName = '/settings'; static const routeName = '/settings';
@ -19,7 +31,7 @@ class SettingsPage extends StatefulWidget {
_SettingsPageState createState() => _SettingsPageState(); _SettingsPageState createState() => _SettingsPageState();
} }
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null); final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
@override @override
@ -29,6 +41,26 @@ class _SettingsPageState extends State<SettingsPage> {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.settingsPageTitle), title: Text(context.l10n.settingsPageTitle),
actions: [
PopupMenuButton<SettingsAction>(
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( body: Theme(
data: theme.copyWith( data: theme.copyWith(
@ -66,4 +98,35 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
); );
} }
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;
}
}
} }