#51 settings: import/export
This commit is contained in:
parent
3a2e0349e2
commit
e2166bd15a
18 changed files with 454 additions and 102 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -538,6 +538,11 @@
|
|||
"settingsSystemDefault": "System",
|
||||
"@settingsSystemDefault": {},
|
||||
|
||||
"settingsActionExport": "Export",
|
||||
"@settingsActionExport": {},
|
||||
"settingsActionImport": "Import",
|
||||
"@settingsActionImport": {},
|
||||
|
||||
"settingsSectionNavigation": "Navigation",
|
||||
"@settingsSectionNavigation": {},
|
||||
"settingsHome": "Home",
|
||||
|
|
|
@ -250,6 +250,9 @@
|
|||
"settingsPageTitle": "설정",
|
||||
"settingsSystemDefault": "시스템",
|
||||
|
||||
"settingsActionExport": "내보내기",
|
||||
"settingsActionImport": "가져오기",
|
||||
|
||||
"settingsSectionNavigation": "탐색",
|
||||
"settingsHome": "홈",
|
||||
"settingsKeepScreenOnTile": "화면 자동 꺼짐 방지",
|
||||
|
|
|
@ -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:
|
||||
|
|
4
lib/model/actions/settings_actions.dart
Normal file
4
lib/model/actions/settings_actions.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
enum SettingsAction {
|
||||
export,
|
||||
import,
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
36
lib/services/output_buffer.dart
Normal file
36
lib/services/output_buffer.dart
Normal 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!;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue