#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.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<MainActivity>()
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<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 {
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
val glideOptions = RequestOptions()
private val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)

View file

@ -177,6 +177,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
companion object {
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
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<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.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<PermissionManager>()
// permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@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)
}

View file

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

View file

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

View file

@ -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:

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) {
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<String, dynamic>) {
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;

View file

@ -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<String> 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<void> reset() {
return _prefs!.clear();
Future<void> 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<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 mp4 = 'video/mp4';
static const json = 'application/json';
// groups
// formats that support transparency

View file

@ -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<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
@ -157,7 +157,7 @@ class PlatformImageFileService implements ImageFileService {
}) {
try {
final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer();
final sink = OutputBuffer();
var bytesReceived = 0;
_byteStreamChannel.receiveBroadcastStream(<String, dynamic>{
'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<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: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<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 {
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<Set<StorageVolume>> getStorageVolumes() async {
@ -113,9 +120,10 @@ class PlatformStorageService implements StorageService {
try {
final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'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<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 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;

View file

@ -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(

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/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<SettingsPage> {
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
@override
@ -29,6 +41,26 @@ class _SettingsPageState extends State<SettingsPage> {
child: Scaffold(
appBar: AppBar(
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(
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;
}
}
}