Merge branch 'photo-enhancement' into dev
|
@ -3,8 +3,9 @@
|
|||
package="com.nkming.nc_photos">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<application
|
||||
|
@ -12,7 +13,8 @@
|
|||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:allowBackup="false"
|
||||
android:largeHeap="true">
|
||||
android:largeHeap="true"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
|
|
|
@ -1,20 +1,37 @@
|
|||
package com.nkming.nc_photos
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.annotation.NonNull
|
||||
import com.nkming.nc_photos.plugin.NcPhotosPlugin
|
||||
import com.nkming.nc_photos.plugin.UriUtil
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.net.URLEncoder
|
||||
|
||||
class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler {
|
||||
companion object {
|
||||
private const val METHOD_CHANNEL = "com.nkming.nc_photos/activity"
|
||||
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (intent.action == NcPhotosPlugin.ACTION_SHOW_IMAGE_PROCESSOR_RESULT) {
|
||||
val route = getRouteFromImageProcessorResult(intent) ?: return
|
||||
Log.i(TAG, "Initial route: $route")
|
||||
_initialRoute = route
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
MediaStoreChannelHandler.CHANNEL
|
||||
).setMethodCallHandler(
|
||||
MediaStoreChannelHandler(this)
|
||||
)
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
SelfSignedCertChannelHandler.CHANNEL
|
||||
|
@ -27,6 +44,9 @@ class MainActivity : FlutterActivity() {
|
|||
).setMethodCallHandler(
|
||||
ShareChannelHandler(this)
|
||||
)
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL
|
||||
).setMethodCallHandler(this)
|
||||
|
||||
EventChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
|
@ -35,4 +55,45 @@ class MainActivity : FlutterActivity() {
|
|||
DownloadEventCancelChannelHandler(this)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
if (intent.action == NcPhotosPlugin.ACTION_SHOW_IMAGE_PROCESSOR_RESULT) {
|
||||
val route = getRouteFromImageProcessorResult(intent) ?: return
|
||||
Log.i(TAG, "Navigate to route: $route")
|
||||
flutterEngine?.navigationChannel?.pushRoute(route)
|
||||
} else {
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"consumeInitialRoute" -> {
|
||||
result.success(_initialRoute)
|
||||
_initialRoute = null
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRouteFromImageProcessorResult(intent: Intent): String? {
|
||||
val resultUri =
|
||||
intent.getParcelableExtra<Uri>(
|
||||
NcPhotosPlugin.EXTRA_IMAGE_RESULT_URI
|
||||
)
|
||||
if (resultUri == null) {
|
||||
Log.e(TAG, "Image result uri == null")
|
||||
return null
|
||||
}
|
||||
val filename = UriUtil.resolveFilename(this, resultUri)?.let {
|
||||
URLEncoder.encode(it, Charsets.UTF_8.toString())
|
||||
}
|
||||
return StringBuilder().apply {
|
||||
append("/enhanced-photo-browser?")
|
||||
if (filename != null) append("filename=$filename")
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private var _initialRoute: String? = null
|
||||
}
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
package com.nkming.nc_photos
|
||||
|
||||
import android.app.Activity
|
||||
import com.nkming.nc_photos.plugin.MediaStoreUtil
|
||||
import com.nkming.nc_photos.plugin.PermissionException
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/*
|
||||
* Save downloaded item on device
|
||||
*
|
||||
* Methods:
|
||||
* Write binary content to a file in the Download directory. Return the Uri to
|
||||
* the file
|
||||
* fun saveFileToDownload(fileName: String, content: ByteArray): String
|
||||
*/
|
||||
class MediaStoreChannelHandler(activity: Activity) :
|
||||
MethodChannel.MethodCallHandler {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val CHANNEL = "com.nkming.nc_photos/media_store"
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"saveFileToDownload" -> {
|
||||
try {
|
||||
saveFileToDownload(
|
||||
call.argument("fileName")!!,
|
||||
call.argument("content")!!,
|
||||
result
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
"copyFileToDownload" -> {
|
||||
try {
|
||||
copyFileToDownload(
|
||||
call.argument("toFileName")!!,
|
||||
call.argument("fromFilePath")!!,
|
||||
result
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFileToDownload(
|
||||
fileName: String, content: ByteArray, result: MethodChannel.Result
|
||||
) {
|
||||
try {
|
||||
val uri =
|
||||
MediaStoreUtil.saveFileToDownload(_context, fileName, content)
|
||||
result.success(uri.toString())
|
||||
} catch (e: PermissionException) {
|
||||
PermissionHandler.ensureWriteExternalStorage(_activity)
|
||||
result.error("permissionError", "Permission not granted", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToDownload(
|
||||
toFileName: String, fromFilePath: String, result: MethodChannel.Result
|
||||
) {
|
||||
try {
|
||||
val uri = MediaStoreUtil.copyFileToDownload(
|
||||
_context, toFileName, fromFilePath
|
||||
)
|
||||
result.success(uri.toString())
|
||||
} catch (e: PermissionException) {
|
||||
PermissionHandler.ensureWriteExternalStorage(_activity)
|
||||
result.error("permissionError", "Permission not granted", null)
|
||||
}
|
||||
}
|
||||
|
||||
private val _activity = activity
|
||||
private val _context get() = _activity
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package com.nkming.nc_photos
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
private const val PERMISSION_REQUEST_CODE = 11011
|
||||
|
||||
class PermissionHandler {
|
||||
companion object {
|
||||
fun ensureWriteExternalStorage(activity: Activity): Boolean {
|
||||
return if (ContextCompat.checkSelfPermission(
|
||||
activity, Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
PERMISSION_REQUEST_CODE
|
||||
)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,8 @@ import 'package:nc_photos/entity/favorite.dart';
|
|||
import 'package:nc_photos/entity/favorite/data_source.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/data_source.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/local_file/data_source.dart';
|
||||
import 'package:nc_photos/entity/person.dart';
|
||||
import 'package:nc_photos/entity/person/data_source.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
|
@ -142,6 +144,11 @@ void _initSelfSignedCertManager() {
|
|||
}
|
||||
|
||||
void _initDiContainer() {
|
||||
LocalFileRepo? localFileRepo;
|
||||
if (platform_k.isAndroid) {
|
||||
// local file currently only supported on Android
|
||||
localFileRepo = const LocalFileRepo(LocalFileMediaStoreDataSource());
|
||||
}
|
||||
KiwiContainer().registerInstance<DiContainer>(DiContainer(
|
||||
albumRepo: AlbumRepo(AlbumCachedDataSource(AppDb())),
|
||||
faceRepo: const FaceRepo(FaceRemoteDataSource()),
|
||||
|
@ -152,6 +159,7 @@ void _initDiContainer() {
|
|||
favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()),
|
||||
tagRepo: const TagRepo(TagRemoteDataSource()),
|
||||
taggedFileRepo: const TaggedFileRepo(TaggedFileRemoteDataSource()),
|
||||
localFileRepo: localFileRepo,
|
||||
appDb: AppDb(),
|
||||
pref: Pref(),
|
||||
));
|
||||
|
|
131
app/lib/bloc/scan_local_dir.dart
Normal file
|
@ -0,0 +1,131 @@
|
|||
import 'package:bloc/bloc.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:nc_photos/iterable_extension.dart';
|
||||
import 'package:nc_photos/use_case/scan_local_dir.dart';
|
||||
|
||||
abstract class ScanLocalDirBlocEvent {
|
||||
const ScanLocalDirBlocEvent();
|
||||
}
|
||||
|
||||
class ScanLocalDirBlocQuery extends ScanLocalDirBlocEvent {
|
||||
const ScanLocalDirBlocQuery(this.relativePaths);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"relativePaths: ${relativePaths.toReadableString()}, "
|
||||
"}";
|
||||
|
||||
final List<String> relativePaths;
|
||||
}
|
||||
|
||||
class _ScanLocalDirBlocFileDeleted extends ScanLocalDirBlocEvent {
|
||||
const _ScanLocalDirBlocFileDeleted(this.files);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"files: ${files.map((f) => f.logTag).toReadableString()}, "
|
||||
"}";
|
||||
|
||||
final List<LocalFile> files;
|
||||
}
|
||||
|
||||
abstract class ScanLocalDirBlocState {
|
||||
const ScanLocalDirBlocState(this.files);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"files: List {length: ${files.length}}, "
|
||||
"}";
|
||||
|
||||
final List<LocalFile> files;
|
||||
}
|
||||
|
||||
class ScanLocalDirBlocInit extends ScanLocalDirBlocState {
|
||||
const ScanLocalDirBlocInit() : super(const []);
|
||||
}
|
||||
|
||||
class ScanLocalDirBlocLoading extends ScanLocalDirBlocState {
|
||||
const ScanLocalDirBlocLoading(List<LocalFile> files) : super(files);
|
||||
}
|
||||
|
||||
class ScanLocalDirBlocSuccess extends ScanLocalDirBlocState {
|
||||
const ScanLocalDirBlocSuccess(List<LocalFile> files) : super(files);
|
||||
}
|
||||
|
||||
class ScanLocalDirBlocFailure extends ScanLocalDirBlocState {
|
||||
const ScanLocalDirBlocFailure(List<LocalFile> files, this.exception)
|
||||
: super(files);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"super: ${super.toString()}, "
|
||||
"exception: $exception, "
|
||||
"}";
|
||||
|
||||
final dynamic exception;
|
||||
}
|
||||
|
||||
class ScanLocalDirBloc
|
||||
extends Bloc<ScanLocalDirBlocEvent, ScanLocalDirBlocState> {
|
||||
ScanLocalDirBloc() : super(const ScanLocalDirBlocInit()) {
|
||||
on<ScanLocalDirBlocQuery>(_onScanLocalDirBlocQuery);
|
||||
on<_ScanLocalDirBlocFileDeleted>(_onScanLocalDirBlocFileDeleted);
|
||||
|
||||
_fileDeletedEventListener.begin();
|
||||
}
|
||||
|
||||
@override
|
||||
close() {
|
||||
_fileDeletedEventListener.end();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _onScanLocalDirBlocQuery(
|
||||
ScanLocalDirBlocQuery event, Emitter<ScanLocalDirBlocState> emit) async {
|
||||
final shouldEmitIntermediate = state.files.isEmpty;
|
||||
try {
|
||||
emit(ScanLocalDirBlocLoading(state.files));
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
final products = <LocalFile>[];
|
||||
for (final p in event.relativePaths) {
|
||||
if (shouldEmitIntermediate) {
|
||||
emit(ScanLocalDirBlocLoading(products));
|
||||
}
|
||||
final files = await ScanLocalDir(c)(p);
|
||||
products.addAll(files);
|
||||
}
|
||||
emit(ScanLocalDirBlocSuccess(products));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
"[_onScanLocalDirBlocQuery] Exception while request", e, stackTrace);
|
||||
emit(ScanLocalDirBlocFailure(state.files, e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onScanLocalDirBlocFileDeleted(
|
||||
_ScanLocalDirBlocFileDeleted event,
|
||||
Emitter<ScanLocalDirBlocState> emit) async {
|
||||
final newFiles = state.files
|
||||
.where((f) => !event.files.any((d) => d.compareIdentity(f)))
|
||||
.toList();
|
||||
if (newFiles.length != state.files.length) {
|
||||
emit(ScanLocalDirBlocSuccess(newFiles));
|
||||
}
|
||||
}
|
||||
|
||||
void _onFileDeletedEvent(LocalFileDeletedEvent ev) {
|
||||
if (state is ScanLocalDirBlocInit) {
|
||||
return;
|
||||
}
|
||||
add(_ScanLocalDirBlocFileDeleted(ev.files));
|
||||
}
|
||||
|
||||
late final _fileDeletedEventListener =
|
||||
AppEventListener<LocalFileDeletedEvent>(_onFileDeletedEvent);
|
||||
|
||||
static final _log = Logger("bloc.scan_local_dir.ScanLocalDirBloc");
|
||||
}
|
|
@ -3,6 +3,7 @@ import 'package:nc_photos/entity/album.dart';
|
|||
import 'package:nc_photos/entity/face.dart';
|
||||
import 'package:nc_photos/entity/favorite.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/person.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/entity/sharee.dart';
|
||||
|
@ -21,6 +22,7 @@ enum DiType {
|
|||
favoriteRepo,
|
||||
tagRepo,
|
||||
taggedFileRepo,
|
||||
localFileRepo,
|
||||
appDb,
|
||||
pref,
|
||||
}
|
||||
|
@ -36,6 +38,7 @@ class DiContainer {
|
|||
FavoriteRepo? favoriteRepo,
|
||||
TagRepo? tagRepo,
|
||||
TaggedFileRepo? taggedFileRepo,
|
||||
LocalFileRepo? localFileRepo,
|
||||
AppDb? appDb,
|
||||
Pref? pref,
|
||||
}) : _albumRepo = albumRepo,
|
||||
|
@ -47,6 +50,7 @@ class DiContainer {
|
|||
_favoriteRepo = favoriteRepo,
|
||||
_tagRepo = tagRepo,
|
||||
_taggedFileRepo = taggedFileRepo,
|
||||
_localFileRepo = localFileRepo,
|
||||
_appDb = appDb,
|
||||
_pref = pref;
|
||||
|
||||
|
@ -70,6 +74,8 @@ class DiContainer {
|
|||
return contianer._tagRepo != null;
|
||||
case DiType.taggedFileRepo:
|
||||
return contianer._taggedFileRepo != null;
|
||||
case DiType.localFileRepo:
|
||||
return contianer._localFileRepo != null;
|
||||
case DiType.appDb:
|
||||
return contianer._appDb != null;
|
||||
case DiType.pref:
|
||||
|
@ -87,6 +93,7 @@ class DiContainer {
|
|||
OrNull<FavoriteRepo>? favoriteRepo,
|
||||
OrNull<TagRepo>? tagRepo,
|
||||
OrNull<TaggedFileRepo>? taggedFileRepo,
|
||||
OrNull<LocalFileRepo>? localFileRepo,
|
||||
OrNull<AppDb>? appDb,
|
||||
OrNull<Pref>? pref,
|
||||
}) {
|
||||
|
@ -101,6 +108,7 @@ class DiContainer {
|
|||
tagRepo: tagRepo == null ? _tagRepo : tagRepo.obj,
|
||||
taggedFileRepo:
|
||||
taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj,
|
||||
localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj,
|
||||
appDb: appDb == null ? _appDb : appDb.obj,
|
||||
pref: pref == null ? _pref : pref.obj,
|
||||
);
|
||||
|
@ -115,6 +123,7 @@ class DiContainer {
|
|||
FavoriteRepo get favoriteRepo => _favoriteRepo!;
|
||||
TagRepo get tagRepo => _tagRepo!;
|
||||
TaggedFileRepo get taggedFileRepo => _taggedFileRepo!;
|
||||
LocalFileRepo get localFileRepo => _localFileRepo!;
|
||||
|
||||
AppDb get appDb => _appDb!;
|
||||
Pref get pref => _pref!;
|
||||
|
@ -128,6 +137,7 @@ class DiContainer {
|
|||
final FavoriteRepo? _favoriteRepo;
|
||||
final TagRepo? _tagRepo;
|
||||
final TaggedFileRepo? _taggedFileRepo;
|
||||
final LocalFileRepo? _localFileRepo;
|
||||
|
||||
final AppDb? _appDb;
|
||||
final Pref? _pref;
|
||||
|
|
|
@ -16,6 +16,7 @@ import 'package:nc_photos/mobile/platform.dart'
|
|||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/use_case/download_file.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class DownloadHandler {
|
||||
|
|
|
@ -11,8 +11,11 @@ bool isSupportedMime(String mime) => _supportedFormatMimes.contains(mime);
|
|||
|
||||
bool isSupportedFormat(File file) => isSupportedMime(file.contentType ?? "");
|
||||
|
||||
bool isSupportedImageMime(String mime) =>
|
||||
isSupportedMime(mime) && mime.startsWith("image/") == true;
|
||||
|
||||
bool isSupportedImageFormat(File file) =>
|
||||
isSupportedFormat(file) && file.contentType?.startsWith("image/") == true;
|
||||
isSupportedImageMime(file.contentType ?? "");
|
||||
|
||||
bool isSupportedVideoFormat(File file) =>
|
||||
isSupportedFormat(file) && file.contentType?.startsWith("video/") == true;
|
||||
|
|
130
app/lib/entity/local_file.dart
Normal file
|
@ -0,0 +1,130 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class LocalFile with EquatableMixin {
|
||||
const LocalFile();
|
||||
|
||||
/// Compare the identity of two local files
|
||||
///
|
||||
/// Return true if two Files point to the same local file on the device. Be
|
||||
/// careful that this does NOT mean that the two objects are identical
|
||||
bool compareIdentity(LocalFile other);
|
||||
|
||||
String get logTag;
|
||||
|
||||
String get filename;
|
||||
DateTime get lastModified;
|
||||
String? get mime;
|
||||
DateTime? get dateTaken;
|
||||
}
|
||||
|
||||
extension LocalFileExtension on LocalFile {
|
||||
DateTime get bestDateTime => dateTaken ?? lastModified;
|
||||
}
|
||||
|
||||
/// A local file represented by its content uri on Android
|
||||
class LocalUriFile with EquatableMixin implements LocalFile {
|
||||
const LocalUriFile({
|
||||
required this.uri,
|
||||
required this.displayName,
|
||||
required this.path,
|
||||
required this.lastModified,
|
||||
this.mime,
|
||||
this.dateTaken,
|
||||
});
|
||||
|
||||
@override
|
||||
compareIdentity(LocalFile other) {
|
||||
if (other is! LocalUriFile) {
|
||||
return false;
|
||||
} else {
|
||||
return uri == other.uri;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
toString() {
|
||||
var product = "$runtimeType {"
|
||||
"uri: $uri, "
|
||||
"displayName: $displayName, "
|
||||
"path: '$path', "
|
||||
"lastModified: $lastModified, ";
|
||||
if (mime != null) {
|
||||
product += "mime: $mime, ";
|
||||
}
|
||||
if (dateTaken != null) {
|
||||
product += "dateTaken: $dateTaken, ";
|
||||
}
|
||||
return product + "}";
|
||||
}
|
||||
|
||||
@override
|
||||
get logTag => path;
|
||||
|
||||
@override
|
||||
get filename => displayName;
|
||||
|
||||
@override
|
||||
get props => [
|
||||
uri,
|
||||
displayName,
|
||||
path,
|
||||
lastModified,
|
||||
mime,
|
||||
dateTaken,
|
||||
];
|
||||
|
||||
final String uri;
|
||||
final String displayName;
|
||||
|
||||
/// [path] could be a relative path or an absolute path
|
||||
final String path;
|
||||
@override
|
||||
final DateTime lastModified;
|
||||
@override
|
||||
final String? mime;
|
||||
@override
|
||||
final DateTime? dateTaken;
|
||||
}
|
||||
|
||||
typedef LocalFileOnFailureListener = void Function(
|
||||
LocalFile file, Object? error, StackTrace? stackTrace);
|
||||
|
||||
class LocalFileRepo {
|
||||
const LocalFileRepo(this.dataSrc);
|
||||
|
||||
/// See [LocalFileDataSource.listDir]
|
||||
Future<List<LocalFile>> listDir(String path) => dataSrc.listDir(path);
|
||||
|
||||
/// See [LocalFileDataSource.deleteFiles]
|
||||
Future<void> deleteFiles(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
}) =>
|
||||
dataSrc.deleteFiles(files, onFailure: onFailure);
|
||||
|
||||
/// See [LocalFileDataSource.shareFiles]
|
||||
Future<void> shareFiles(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
}) =>
|
||||
dataSrc.shareFiles(files, onFailure: onFailure);
|
||||
|
||||
final LocalFileDataSource dataSrc;
|
||||
}
|
||||
|
||||
abstract class LocalFileDataSource {
|
||||
/// List all files under [path]
|
||||
Future<List<LocalFile>> listDir(String path);
|
||||
|
||||
/// Delete files
|
||||
Future<void> deleteFiles(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
});
|
||||
|
||||
/// Share files
|
||||
Future<void> shareFiles(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
});
|
||||
}
|
124
app/lib/entity/local_file/data_source.dart
Normal file
|
@ -0,0 +1,124 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/iterable_extension.dart';
|
||||
import 'package:nc_photos/mobile/android/android_info.dart';
|
||||
import 'package:nc_photos/mobile/android/k.dart' as android;
|
||||
import 'package:nc_photos/mobile/share.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
class LocalFileMediaStoreDataSource implements LocalFileDataSource {
|
||||
const LocalFileMediaStoreDataSource();
|
||||
|
||||
@override
|
||||
listDir(String path) async {
|
||||
_log.info("[listDir] $path");
|
||||
final results = await MediaStore.queryFiles(path);
|
||||
return results
|
||||
.where((r) => file_util.isSupportedMime(r.mimeType ?? ""))
|
||||
.map(_toLocalFile)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
deleteFiles(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
}) async {
|
||||
_log.info("[deleteFiles] ${files.map((f) => f.logTag).toReadableString()}");
|
||||
final uriFiles = _filterUriFiles(files, (f) {
|
||||
onFailure?.call(f, ArgumentError("File not supported"), null);
|
||||
});
|
||||
if (AndroidInfo().sdkInt >= AndroidVersion.R) {
|
||||
await _deleteFiles30(uriFiles, onFailure);
|
||||
} else {
|
||||
await _deleteFiles0(uriFiles, onFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
shareFiles(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
}) async {
|
||||
_log.info("[shareFiles] ${files.map((f) => f.logTag).toReadableString()}");
|
||||
final uriFiles = _filterUriFiles(files, (f) {
|
||||
onFailure?.call(f, ArgumentError("File not supported"), null);
|
||||
});
|
||||
|
||||
final share = AndroidFileShare(uriFiles.map((e) => e.uri).toList(),
|
||||
uriFiles.map((e) => e.mime).toList());
|
||||
try {
|
||||
await share.share();
|
||||
} catch (e, stackTrace) {
|
||||
for (final f in uriFiles) {
|
||||
onFailure?.call(f, e, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFiles30(
|
||||
List<LocalUriFile> files, LocalFileOnFailureListener? onFailure) async {
|
||||
assert(AndroidInfo().sdkInt >= AndroidVersion.R);
|
||||
int? resultCode;
|
||||
final resultFuture = MediaStore.stream
|
||||
.whereType<MediaStoreDeleteRequestResultEvent>()
|
||||
.first
|
||||
.then((ev) => resultCode = ev.resultCode);
|
||||
await MediaStore.deleteFiles(files.map((f) => f.uri).toList());
|
||||
await resultFuture;
|
||||
if (resultCode != android.resultOk) {
|
||||
_log.warning("[_deleteFiles30] result != OK: $resultCode");
|
||||
for (final f in files) {
|
||||
onFailure?.call(f, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFiles0(
|
||||
List<LocalUriFile> files, LocalFileOnFailureListener? onFailure) async {
|
||||
assert(AndroidInfo().sdkInt < AndroidVersion.R);
|
||||
final failedUris =
|
||||
await MediaStore.deleteFiles(files.map((f) => f.uri).toList());
|
||||
final failedFilesIt = failedUris!
|
||||
.map((uri) => files.firstWhereOrNull((f) => f.uri == uri))
|
||||
.whereNotNull();
|
||||
for (final f in failedFilesIt) {
|
||||
onFailure?.call(f, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
List<LocalUriFile> _filterUriFiles(
|
||||
List<LocalFile> files, [
|
||||
void Function(LocalFile)? nonUriFileCallback,
|
||||
]) {
|
||||
return files
|
||||
.where((f) {
|
||||
if (f is! LocalUriFile) {
|
||||
_log.warning(
|
||||
"[deleteFiles] Can't remove file not returned by this data source: $f");
|
||||
nonUriFileCallback?.call(f);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.cast<LocalUriFile>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static LocalFile _toLocalFile(MediaStoreQueryResult r) => LocalUriFile(
|
||||
uri: r.uri,
|
||||
displayName: r.displayName,
|
||||
path: r.path,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(r.dateModified),
|
||||
mime: r.mimeType,
|
||||
dateTaken: r.dateTaken?.run(DateTime.fromMillisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
static final _log =
|
||||
Logger("entity.local_file.data_source.LocalFileMediaStoreDataSource");
|
||||
}
|
|
@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/pref.dart';
|
||||
|
||||
|
@ -148,6 +149,12 @@ class PrefUpdatedEvent {
|
|||
final dynamic value;
|
||||
}
|
||||
|
||||
class LocalFileDeletedEvent {
|
||||
const LocalFileDeletedEvent(this.files);
|
||||
|
||||
final List<LocalFile> files;
|
||||
}
|
||||
|
||||
extension FilePropertyUpdatedEventExtension on FilePropertyUpdatedEvent {
|
||||
bool hasAnyProperties(List<int> properties) =>
|
||||
properties.any((p) => this.properties & p != 0);
|
||||
|
|
|
@ -34,22 +34,6 @@ class ApiException implements Exception {
|
|||
final dynamic message;
|
||||
}
|
||||
|
||||
/// Platform permission is not granted by user
|
||||
class PermissionException implements Exception {
|
||||
PermissionException([this.message]);
|
||||
|
||||
@override
|
||||
toString() {
|
||||
if (message == null) {
|
||||
return "PermissionException";
|
||||
} else {
|
||||
return "PermissionException: $message";
|
||||
}
|
||||
}
|
||||
|
||||
final dynamic message;
|
||||
}
|
||||
|
||||
/// The Nextcloud base URL address is invalid
|
||||
class InvalidBaseUrlException implements Exception {
|
||||
InvalidBaseUrlException([this.message]);
|
||||
|
|
15
app/lib/file_extension.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as path_lib;
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
extension FileExtension on File {
|
||||
Future<String?> readMime() async {
|
||||
final header = await openRead(0, defaultMagicNumbersMaxLength)
|
||||
.expand((element) => element)
|
||||
.toList();
|
||||
return lookupMimeType(path, headerBytes: header);
|
||||
}
|
||||
|
||||
String get filename => path_lib.basename(path);
|
||||
}
|
|
@ -6,3 +6,5 @@ const twoFactorAuthUrl =
|
|||
"https://gitlab.com/nkming2/nc-photos/-/wikis/help/two-factor-authentication";
|
||||
const homeFolderNotFoundUrl =
|
||||
"https://gitlab.com/nkming2/nc-photos/-/wikis/help/home-folder-not-found";
|
||||
const enhanceZeroDceUrl =
|
||||
"https://gitlab.com/nkming2/nc-photos/-/wikis/help/enhance/zero-dce";
|
||||
|
|
|
@ -1171,6 +1171,22 @@
|
|||
"@metadataTaskPauseLowBatteryNotification": {
|
||||
"description": "Shown when the app has paused reading image metadata due to low battery"
|
||||
},
|
||||
"enhanceTooltip": "Enhance",
|
||||
"@enhanceTooltip": {
|
||||
"description": "Enhance a photo"
|
||||
},
|
||||
"enhanceLowLightTitle": "Low-light enhancement",
|
||||
"@enhanceLowLightTitle": {
|
||||
"description": "Enhance a photo taken in low-light environment"
|
||||
},
|
||||
"collectionEnhancedPhotosLabel": "Enhanced photos",
|
||||
"@collectionEnhancedPhotosLabel": {
|
||||
"description": "List photos enhanced by the app"
|
||||
},
|
||||
"deletePermanentlyLocalConfirmationDialogContent": "Selected items will be deleted permanently from this device.\n\nThis action is nonreversible",
|
||||
"@deletePermanentlyLocalConfirmationDialogContent": {
|
||||
"description": "Make sure the user wants to delete the items from the current device"
|
||||
},
|
||||
|
||||
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||
"@errorUnauthenticated": {
|
||||
|
|
|
@ -84,6 +84,10 @@
|
|||
"tagPickerNoTagSelectedNotification",
|
||||
"backgroundServiceStopping",
|
||||
"metadataTaskPauseLowBatteryNotification",
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -186,6 +190,10 @@
|
|||
"tagPickerNoTagSelectedNotification",
|
||||
"backgroundServiceStopping",
|
||||
"metadataTaskPauseLowBatteryNotification",
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -343,6 +351,10 @@
|
|||
"tagPickerNoTagSelectedNotification",
|
||||
"backgroundServiceStopping",
|
||||
"metadataTaskPauseLowBatteryNotification",
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -350,14 +362,29 @@
|
|||
"rootPickerSkipConfirmationDialogContent2",
|
||||
"helpButtonLabel",
|
||||
"backgroundServiceStopping",
|
||||
"metadataTaskPauseLowBatteryNotification"
|
||||
"metadataTaskPauseLowBatteryNotification",
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent"
|
||||
],
|
||||
|
||||
"fi": [
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"collectionsTooltip",
|
||||
"helpTooltip",
|
||||
"helpButtonLabel",
|
||||
"removeFromAlbumTooltip"
|
||||
"removeFromAlbumTooltip",
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent"
|
||||
],
|
||||
|
||||
"pl": [
|
||||
|
@ -381,6 +408,24 @@
|
|||
"addTagInputHint",
|
||||
"tagPickerNoTagSelectedNotification",
|
||||
"backgroundServiceStopping",
|
||||
"metadataTaskPauseLowBatteryNotification"
|
||||
"metadataTaskPauseLowBatteryNotification",
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"enhanceTooltip",
|
||||
"enhanceLowLightTitle",
|
||||
"collectionEnhancedPhotosLabel",
|
||||
"deletePermanentlyLocalConfirmationDialogContent"
|
||||
]
|
||||
}
|
||||
|
|
8
app/lib/mobile/android/activity.dart
Normal file
|
@ -0,0 +1,8 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
class Activity {
|
||||
static Future<String?> consumeInitialRoute() =>
|
||||
_methodChannel.invokeMethod("consumeInitialRoute");
|
||||
|
||||
static const _methodChannel = MethodChannel("com.nkming.nc_photos/activity");
|
||||
}
|
64
app/lib/mobile/android/content_uri_image_provider.dart
Normal file
|
@ -0,0 +1,64 @@
|
|||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
class ContentUriImage extends ImageProvider<ContentUriImage>
|
||||
with EquatableMixin {
|
||||
/// Creates an object that decodes a content Uri as an image.
|
||||
const ContentUriImage(
|
||||
this.uri, {
|
||||
this.scale = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<ContentUriImage>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
load(ContentUriImage key, DecoderCallback decode) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
debugLabel: key.uri,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
ErrorDescription("Content uri: $uri"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadAsync(
|
||||
ContentUriImage key, DecoderCallback decode) async {
|
||||
assert(key == this);
|
||||
|
||||
final bytes = await ContentUri.readUri(uri);
|
||||
|
||||
if (bytes.lengthInBytes == 0) {
|
||||
// The file may become available later.
|
||||
PaintingBinding.instance!.imageCache!.evict(key);
|
||||
throw StateError("$uri is empty and cannot be loaded as an image.");
|
||||
}
|
||||
|
||||
return decode(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
get props => [
|
||||
uri,
|
||||
scale,
|
||||
];
|
||||
|
||||
@override
|
||||
toString() => "${objectRuntimeType(this, "ContentUriImage")} {"
|
||||
"uri: $uri, "
|
||||
"scale: $scale, "
|
||||
"}";
|
||||
|
||||
final String uri;
|
||||
|
||||
/// The scale to place in the [ImageInfo] object of the image.
|
||||
final double scale;
|
||||
}
|
8
app/lib/mobile/android/k.dart
Normal file
|
@ -0,0 +1,8 @@
|
|||
/// Standard activity result: operation canceled.
|
||||
const resultCanceled = 0;
|
||||
|
||||
/// Standard activity result: operation succeeded.
|
||||
const resultOk = -1;
|
||||
|
||||
/// Start of user-defined activity results.
|
||||
const resultFirstUser = 1;
|
|
@ -1,44 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:nc_photos/exception.dart';
|
||||
|
||||
class MediaStore {
|
||||
static Future<String> saveFileToDownload(
|
||||
String fileName, Uint8List fileContent) async {
|
||||
try {
|
||||
return (await _channel
|
||||
.invokeMethod<String>("saveFileToDownload", <String, dynamic>{
|
||||
"fileName": fileName,
|
||||
"content": fileContent,
|
||||
}))!;
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _exceptionCodePermissionError) {
|
||||
throw PermissionException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> copyFileToDownload(
|
||||
String toFileName, String fromFilePath) async {
|
||||
try {
|
||||
return (await _channel
|
||||
.invokeMethod<String>("copyFileToDownload", <String, dynamic>{
|
||||
"toFileName": toFileName,
|
||||
"fromFilePath": fromFilePath,
|
||||
}))!;
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _exceptionCodePermissionError) {
|
||||
throw PermissionException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static const _exceptionCodePermissionError = "permissionError";
|
||||
|
||||
static const _channel = MethodChannel("com.nkming.nc_photos/media_store");
|
||||
}
|
18
app/lib/mobile/android/permission_util.dart
Normal file
|
@ -0,0 +1,18 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
Future<Map<String, int>> requestPermissionsForResult(
|
||||
List<String> permissions) async {
|
||||
Map<String, int>? result;
|
||||
final resultFuture = Permission.stream
|
||||
.whereType<PermissionRequestResult>()
|
||||
.first
|
||||
.then((ev) => result = ev.grantResults);
|
||||
await Permission.request(permissions);
|
||||
await resultFuture;
|
||||
_log.info("[requestPermissionsForResult] Result: $result");
|
||||
return result!;
|
||||
}
|
||||
|
||||
final _log = Logger("mobile.android.permission_util");
|
|
@ -4,9 +4,9 @@ import 'dart:io';
|
|||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/exception.dart';
|
||||
import 'package:nc_photos/mobile/android/media_store.dart';
|
||||
import 'package:nc_photos/platform/download.dart' as itf;
|
||||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
|
@ -92,13 +92,11 @@ class _AndroidDownload extends itf.Download {
|
|||
}
|
||||
|
||||
// copy the file to the actual dir
|
||||
final String path;
|
||||
if (parentDir?.isNotEmpty == true) {
|
||||
path = "$parentDir/$filename";
|
||||
} else {
|
||||
path = filename;
|
||||
}
|
||||
return await MediaStore.copyFileToDownload(path, file.path);
|
||||
return await MediaStore.copyFileToDownload(
|
||||
file.path,
|
||||
filename: filename,
|
||||
subDir: parentDir,
|
||||
);
|
||||
} finally {
|
||||
file.delete();
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:nc_photos/mobile/android/media_store.dart';
|
||||
import 'package:nc_photos/platform/file_saver.dart' as itf;
|
||||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
class FileSaver extends itf.FileSaver {
|
||||
@override
|
||||
|
@ -15,5 +15,5 @@ class FileSaver extends itf.FileSaver {
|
|||
}
|
||||
|
||||
Future<String> _saveFileAndroid(String filename, Uint8List content) =>
|
||||
MediaStore.saveFileToDownload(filename, content);
|
||||
MediaStore.saveFileToDownload(content, filename);
|
||||
}
|
||||
|
|
|
@ -2,3 +2,4 @@ import 'package:nc_photos/platform/k.dart' as platform_k;
|
|||
|
||||
final isSupportMapView = platform_k.isWeb || platform_k.isAndroid;
|
||||
final isSupportSelfSignedCert = platform_k.isAndroid;
|
||||
final isSupportEnhancement = platform_k.isAndroid;
|
||||
|
|
|
@ -3,15 +3,18 @@ import 'dart:math';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_db.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/data_source.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/entity/share/data_source.dart';
|
||||
import 'package:nc_photos/exception.dart';
|
||||
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||
import 'package:nc_photos/iterable_extension.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
|
@ -23,10 +26,12 @@ import 'package:nc_photos/use_case/copy.dart';
|
|||
import 'package:nc_photos/use_case/create_dir.dart';
|
||||
import 'package:nc_photos/use_case/create_share.dart';
|
||||
import 'package:nc_photos/use_case/download_file.dart';
|
||||
import 'package:nc_photos/use_case/share_local.dart';
|
||||
import 'package:nc_photos/widget/processing_dialog.dart';
|
||||
import 'package:nc_photos/widget/share_link_multiple_files_dialog.dart';
|
||||
import 'package:nc_photos/widget/share_method_dialog.dart';
|
||||
import 'package:nc_photos/widget/simple_input_dialog.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
/// Handle sharing to other apps
|
||||
|
@ -36,6 +41,32 @@ class ShareHandler {
|
|||
this.clearSelection,
|
||||
});
|
||||
|
||||
Future<void> shareLocalFiles(List<LocalFile> files) async {
|
||||
if (!isSelectionCleared) {
|
||||
clearSelection?.call();
|
||||
}
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
var hasShownError = false;
|
||||
await ShareLocal(c)(
|
||||
files,
|
||||
onFailure: (f, e, stackTrace) {
|
||||
if (e != null) {
|
||||
_log.shout(
|
||||
"[shareLocalFiles] Failed while sharing file: ${logFilename(f.logTag)}",
|
||||
e,
|
||||
stackTrace);
|
||||
if (!hasShownError) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(e)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
hasShownError = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> shareFiles(Account account, List<File> files) async {
|
||||
try {
|
||||
final method = await _askShareMethod();
|
||||
|
|
32
app/lib/use_case/delete_local.dart
Normal file
|
@ -0,0 +1,32 @@
|
|||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
|
||||
class DeleteLocal {
|
||||
DeleteLocal(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.localFileRepo);
|
||||
|
||||
Future<void> call(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
}) async {
|
||||
final deleted = List.of(files);
|
||||
await _c.localFileRepo.deleteFiles(files, onFailure: (f, e, stackTrace) {
|
||||
deleted.removeWhere((d) => d.compareIdentity(f));
|
||||
onFailure?.call(f, e, stackTrace);
|
||||
});
|
||||
if (deleted.isNotEmpty) {
|
||||
_log.info("[call] Deleted ${deleted.length} files successfully");
|
||||
KiwiContainer().resolve<EventBus>().fire(LocalFileDeletedEvent(deleted));
|
||||
}
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
|
||||
static final _log = Logger("use_case.delete_local.DeleteLocal");
|
||||
}
|
|
@ -1,19 +1,23 @@
|
|||
import 'dart:io' as io;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:exifdart/exifdart.dart' as exifdart;
|
||||
import 'package:exifdart/exifdart_io.dart';
|
||||
import 'package:exifdart/exifdart_memory.dart';
|
||||
import 'package:image_size_getter/image_size_getter.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/entity/exif.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file.dart' as app;
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/file_extension.dart';
|
||||
import 'package:nc_photos/image_size_getter_util.dart';
|
||||
|
||||
class LoadMetadata {
|
||||
/// Load metadata of [binary], which is the content of [file]
|
||||
Future<Metadata> loadRemote(Account account, File file, Uint8List binary) {
|
||||
Future<app.Metadata> loadRemote(
|
||||
Account account, app.File file, Uint8List binary) {
|
||||
return _loadMetadata(
|
||||
mime: file.contentType ?? "",
|
||||
exifdartReaderBuilder: () => MemoryBlobReader(binary),
|
||||
|
@ -22,7 +26,20 @@ class LoadMetadata {
|
|||
);
|
||||
}
|
||||
|
||||
Future<Metadata> _loadMetadata({
|
||||
Future<app.Metadata> loadLocal(
|
||||
io.File file, {
|
||||
String? mime,
|
||||
}) async {
|
||||
mime = mime ?? await file.readMime();
|
||||
return _loadMetadata(
|
||||
mime: mime ?? "",
|
||||
exifdartReaderBuilder: () => FileReader(file),
|
||||
imageSizeGetterInputBuilder: () => AsyncFileInput(file),
|
||||
filename: file.path,
|
||||
);
|
||||
}
|
||||
|
||||
Future<app.Metadata> _loadMetadata({
|
||||
required String mime,
|
||||
required exifdart.AbstractBlobReader Function() exifdartReaderBuilder,
|
||||
required AsyncImageInput Function() imageSizeGetterInputBuilder,
|
||||
|
@ -86,7 +103,7 @@ class LoadMetadata {
|
|||
return _buildMetadata(map);
|
||||
}
|
||||
|
||||
Metadata _buildMetadata(Map<String, dynamic> map) {
|
||||
app.Metadata _buildMetadata(Map<String, dynamic> map) {
|
||||
int? imageWidth, imageHeight;
|
||||
Exif? exif;
|
||||
if (map.containsKey("resolution")) {
|
||||
|
@ -96,7 +113,7 @@ class LoadMetadata {
|
|||
if (map.containsKey("exif")) {
|
||||
exif = Exif(map["exif"]);
|
||||
}
|
||||
return Metadata(
|
||||
return app.Metadata(
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
exif: exif,
|
||||
|
|
20
app/lib/use_case/scan_local_dir.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
|
||||
class ScanLocalDir {
|
||||
ScanLocalDir(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.localFileRepo);
|
||||
|
||||
/// List all files under a local dir recursively
|
||||
Future<List<LocalFile>> call(String relativePath) async {
|
||||
final files = await _c.localFileRepo.listDir(relativePath);
|
||||
return files
|
||||
.where((f) => file_util.isSupportedImageMime(f.mime ?? ""))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
}
|
26
app/lib/use_case/share_local.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
|
||||
class ShareLocal {
|
||||
ShareLocal(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.localFileRepo);
|
||||
|
||||
Future<void> call(
|
||||
List<LocalFile> files, {
|
||||
LocalFileOnFailureListener? onFailure,
|
||||
}) async {
|
||||
var count = files.length;
|
||||
await _c.localFileRepo.shareFiles(files, onFailure: (f, e, stackTrace) {
|
||||
--count;
|
||||
onFailure?.call(f, e, stackTrace);
|
||||
});
|
||||
_log.info("[call] Shared $count files successfully");
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
|
||||
static final _log = Logger("use_case.share_local.ShareLocal");
|
||||
}
|
445
app/lib/widget/enhanced_photo_browser.dart
Normal file
|
@ -0,0 +1,445 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/bloc/scan_local_dir.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||
import 'package:nc_photos/iterable_extension.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/mobile/android/android_info.dart';
|
||||
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
|
||||
import 'package:nc_photos/mobile/android/permission_util.dart';
|
||||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/share_handler.dart';
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/widget/empty_list_indicator.dart';
|
||||
import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart';
|
||||
import 'package:nc_photos/widget/local_file_viewer.dart';
|
||||
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
|
||||
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
|
||||
import 'package:nc_photos/widget/selection_app_bar.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
class EnhancedPhotoBrowserArguments {
|
||||
const EnhancedPhotoBrowserArguments(this.filename);
|
||||
|
||||
final String? filename;
|
||||
}
|
||||
|
||||
class EnhancedPhotoBrowser extends StatefulWidget {
|
||||
static const routeName = "/enhanced-photo-browser";
|
||||
|
||||
static Route buildRoute(EnhancedPhotoBrowserArguments args) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EnhancedPhotoBrowser.fromArgs(args),
|
||||
);
|
||||
|
||||
const EnhancedPhotoBrowser({
|
||||
Key? key,
|
||||
required this.filename,
|
||||
}) : super(key: key);
|
||||
|
||||
EnhancedPhotoBrowser.fromArgs(EnhancedPhotoBrowserArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
filename: args.filename,
|
||||
);
|
||||
|
||||
@override
|
||||
createState() => _EnhancedPhotoBrowserState();
|
||||
|
||||
final String? filename;
|
||||
}
|
||||
|
||||
class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
|
||||
with SelectableItemStreamListMixin<EnhancedPhotoBrowser> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
|
||||
_ensurePermission().then((value) {
|
||||
if (value) {
|
||||
_initBloc();
|
||||
} else {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isNoPermission = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return AppTheme(
|
||||
child: Scaffold(
|
||||
body: BlocListener<ScanLocalDirBloc, ScanLocalDirBlocState>(
|
||||
bloc: _bloc,
|
||||
listener: (context, state) => _onStateChange(context, state),
|
||||
child: BlocBuilder<ScanLocalDirBloc, ScanLocalDirBlocState>(
|
||||
bloc: _bloc,
|
||||
builder: (context, state) => _buildContent(context, state),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initBloc() {
|
||||
if (_bloc.state is ScanLocalDirBlocInit) {
|
||||
_log.info("[_initBloc] Initialize bloc");
|
||||
_reqQuery();
|
||||
} else {
|
||||
// process the current state
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_onStateChange(context, _bloc.state);
|
||||
});
|
||||
_reqQuery();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, ScanLocalDirBlocState state) {
|
||||
if (_isNoPermission) {
|
||||
return Column(
|
||||
children: [
|
||||
AppBar(
|
||||
title: Text(L10n.global().collectionEnhancedPhotosLabel),
|
||||
elevation: 0,
|
||||
),
|
||||
Expanded(
|
||||
child: EmptyListIndicator(
|
||||
icon: Icons.folder_off_outlined,
|
||||
text: L10n.global().errorNoStoragePermission,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is ScanLocalDirBlocSuccess &&
|
||||
itemStreamListItems.isEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
AppBar(
|
||||
title: Text(L10n.global().collectionEnhancedPhotosLabel),
|
||||
elevation: 0,
|
||||
),
|
||||
Expanded(
|
||||
child: EmptyListIndicator(
|
||||
icon: Icons.folder_outlined,
|
||||
text: L10n.global().listEmptyText,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Stack(
|
||||
children: [
|
||||
buildItemStreamListOuter(
|
||||
context,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
secondary: AppTheme.getOverscrollIndicatorColor(context),
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context),
|
||||
buildItemStreamList(
|
||||
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state is ScanLocalDirBlocLoading)
|
||||
const Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
if (isSelectionMode) {
|
||||
return _buildSelectionAppBar(context);
|
||||
} else {
|
||||
return _buildNormalAppBar(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNormalAppBar(BuildContext context) => SliverAppBar(
|
||||
title: Text(L10n.global().collectionEnhancedPhotosLabel),
|
||||
);
|
||||
|
||||
Widget _buildSelectionAppBar(BuildContext context) {
|
||||
return SelectionAppBar(
|
||||
count: selectedListItems.length,
|
||||
onClosePressed: () {
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
},
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: L10n.global().shareTooltip,
|
||||
onPressed: () {
|
||||
_onSelectionSharePressed(context);
|
||||
},
|
||||
),
|
||||
PopupMenuButton<_SelectionMenuOption>(
|
||||
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _SelectionMenuOption.delete,
|
||||
child: Text(L10n.global().deletePermanentlyTooltip),
|
||||
),
|
||||
],
|
||||
onSelected: (option) => _onSelectionMenuSelected(context, option),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onStateChange(BuildContext context, ScanLocalDirBlocState state) {
|
||||
if (state is ScanLocalDirBlocInit) {
|
||||
itemStreamListItems = [];
|
||||
} else if (state is ScanLocalDirBlocLoading) {
|
||||
_transformItems(state.files);
|
||||
} else if (state is ScanLocalDirBlocSuccess) {
|
||||
_transformItems(state.files);
|
||||
if (_isFirstRun) {
|
||||
_isFirstRun = false;
|
||||
if (widget.filename != null) {
|
||||
_openInitialImage(widget.filename!);
|
||||
}
|
||||
}
|
||||
} else if (state is ScanLocalDirBlocFailure) {
|
||||
_transformItems(state.files);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(state.exception is PermissionException
|
||||
? L10n.global().errorNoStoragePermission
|
||||
: exception_util.toUserString(state.exception)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSelectionSharePressed(BuildContext context) async {
|
||||
final selected = selectedListItems
|
||||
.whereType<_FileListItem>()
|
||||
.map((e) => e.file)
|
||||
.toList();
|
||||
await ShareHandler(
|
||||
context: context,
|
||||
clearSelection: () {
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
},
|
||||
).shareLocalFiles(selected);
|
||||
}
|
||||
|
||||
void _onSelectionMenuSelected(
|
||||
BuildContext context, _SelectionMenuOption option) {
|
||||
switch (option) {
|
||||
case _SelectionMenuOption.delete:
|
||||
_onSelectionDeletePressed(context);
|
||||
break;
|
||||
default:
|
||||
_log.shout("[_onSelectionMenuSelected] Unknown option: $option");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSelectionDeletePressed(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(L10n.global().deletePermanentlyConfirmationDialogTitle),
|
||||
content: Text(
|
||||
L10n.global().deletePermanentlyLocalConfirmationDialogContent,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(L10n.global().confirmButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedFiles = selectedListItems
|
||||
.whereType<_FileListItem>()
|
||||
.map((e) => e.file)
|
||||
.toList();
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles);
|
||||
}
|
||||
|
||||
void _onItemTap(int index) {
|
||||
Navigator.pushNamed(context, LocalFileViewer.routeName,
|
||||
arguments: LocalFileViewerArguments(_backingFiles, index));
|
||||
}
|
||||
|
||||
void _transformItems(List<LocalFile> files) {
|
||||
// we use last modified here to keep newly enhanced photo at the top
|
||||
_backingFiles =
|
||||
files.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
|
||||
itemStreamListItems = () sync* {
|
||||
for (int i = 0; i < _backingFiles.length; ++i) {
|
||||
final f = _backingFiles[i];
|
||||
if (file_util.isSupportedImageMime(f.mime ?? "")) {
|
||||
yield _ImageListItem(
|
||||
file: f,
|
||||
onTap: () => _onItemTap(i),
|
||||
);
|
||||
}
|
||||
}
|
||||
}()
|
||||
.toList();
|
||||
_log.info("[_transformItems] Length: ${itemStreamListItems.length}");
|
||||
}
|
||||
|
||||
void _openInitialImage(String filename) {
|
||||
final index = _backingFiles.indexWhere((f) => f.filename == filename);
|
||||
if (index == -1) {
|
||||
_log.severe("[openInitialImage] Filename not found: $filename");
|
||||
return;
|
||||
}
|
||||
Navigator.pushNamed(context, LocalFileViewer.routeName,
|
||||
arguments: LocalFileViewerArguments(_backingFiles, index));
|
||||
}
|
||||
|
||||
Future<bool> _ensurePermission() async {
|
||||
if (platform_k.isAndroid) {
|
||||
if (AndroidInfo().sdkInt >= AndroidVersion.R) {
|
||||
if (!await Permission.hasReadExternalStorage()) {
|
||||
final results = await requestPermissionsForResult([
|
||||
Permission.READ_EXTERNAL_STORAGE,
|
||||
]);
|
||||
return results[Permission.READ_EXTERNAL_STORAGE] ==
|
||||
PermissionRequestResult.granted;
|
||||
}
|
||||
} else {
|
||||
if (!await Permission.hasWriteExternalStorage()) {
|
||||
final results = await requestPermissionsForResult([
|
||||
Permission.WRITE_EXTERNAL_STORAGE,
|
||||
]);
|
||||
return results[Permission.WRITE_EXTERNAL_STORAGE] ==
|
||||
PermissionRequestResult.granted;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _reqQuery() {
|
||||
_bloc.add(const ScanLocalDirBlocQuery(
|
||||
["Download/Photos (for Nextcloud)/Enhanced Photos"]));
|
||||
}
|
||||
|
||||
final _bloc = ScanLocalDirBloc();
|
||||
|
||||
var _backingFiles = <LocalFile>[];
|
||||
|
||||
var _isFirstRun = true;
|
||||
var _thumbZoomLevel = 0;
|
||||
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
|
||||
var _isNoPermission = false;
|
||||
|
||||
static final _log =
|
||||
Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState");
|
||||
}
|
||||
|
||||
abstract class _ListItem implements SelectableItem {
|
||||
_ListItem({
|
||||
VoidCallback? onTap,
|
||||
}) : _onTap = onTap;
|
||||
|
||||
@override
|
||||
get onTap => _onTap;
|
||||
|
||||
@override
|
||||
get isSelectable => true;
|
||||
|
||||
@override
|
||||
get staggeredTile => const StaggeredTile.count(1, 1);
|
||||
|
||||
final VoidCallback? _onTap;
|
||||
}
|
||||
|
||||
abstract class _FileListItem extends _ListItem {
|
||||
_FileListItem({
|
||||
required this.file,
|
||||
VoidCallback? onTap,
|
||||
}) : super(onTap: onTap);
|
||||
|
||||
final LocalFile file;
|
||||
}
|
||||
|
||||
class _ImageListItem extends _FileListItem {
|
||||
_ImageListItem({
|
||||
required LocalFile file,
|
||||
VoidCallback? onTap,
|
||||
}) : super(file: file, onTap: onTap);
|
||||
|
||||
@override
|
||||
buildWidget(BuildContext context) {
|
||||
final ImageProvider provider;
|
||||
if (file is LocalUriFile) {
|
||||
provider = ContentUriImage((file as LocalUriFile).uri);
|
||||
} else {
|
||||
throw ArgumentError("Invalid file");
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: FittedBox(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
fit: BoxFit.cover,
|
||||
child: Container(
|
||||
// arbitrary size here
|
||||
constraints: BoxConstraints.tight(const Size(128, 128)),
|
||||
color: AppTheme.getListItemBackgroundColor(context),
|
||||
child: Image(
|
||||
image: ResizeImage.resizeIfNeeded(k.photoThumbSize, null, provider),
|
||||
filterQuality: FilterQuality.high,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, e, stackTrace) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 64,
|
||||
color: Colors.white.withOpacity(.8),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _SelectionMenuOption {
|
||||
delete,
|
||||
}
|
51
app/lib/widget/handler/delete_local_selection_handler.dart
Normal file
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/use_case/delete_local.dart';
|
||||
|
||||
class DeleteLocalSelectionHandler {
|
||||
const DeleteLocalSelectionHandler();
|
||||
|
||||
/// Delete [selectedFiles] permanently from device
|
||||
Future<int> call({
|
||||
required List<LocalFile> selectedFiles,
|
||||
bool isRemoveOpened = false,
|
||||
}) async {
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
var failureCount = 0;
|
||||
await DeleteLocal(c)(
|
||||
selectedFiles,
|
||||
onFailure: (file, e, stackTrace) {
|
||||
if (e != null) {
|
||||
_log.shout(
|
||||
"[call] Failed while deleting file: ${logFilename(file.logTag)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
++failureCount;
|
||||
},
|
||||
);
|
||||
if (failureCount == 0) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global().deleteSelectedSuccessNotification),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
} else {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(L10n.global().deleteSelectedFailureNotification(failureCount)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
return selectedFiles.length - failureCount;
|
||||
}
|
||||
|
||||
static final _log = Logger(
|
||||
"widget.handler.delete_local_selection_handler.DeleteLocalSelectionHandler");
|
||||
}
|
132
app/lib/widget/handler/enhance_handler.dart
Normal file
|
@ -0,0 +1,132 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/api/api.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/help_utils.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/mobile/android/android_info.dart';
|
||||
import 'package:nc_photos/mobile/android/permission_util.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class EnhanceHandler {
|
||||
const EnhanceHandler({
|
||||
required this.account,
|
||||
required this.file,
|
||||
});
|
||||
|
||||
static bool isSupportedFormat(File file) =>
|
||||
file_util.isSupportedImageFormat(file) && file.contentType != "image/gif";
|
||||
|
||||
Future<void> call(BuildContext context) async {
|
||||
if (!await _ensurePermission()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final selected = await showDialog<_Algorithm>(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
children: _getOptions()
|
||||
.map((o) => SimpleDialogOption(
|
||||
padding: const EdgeInsets.all(0),
|
||||
child: ListTile(
|
||||
title: Text(o.title),
|
||||
subtitle: o.subtitle?.run((t) => Text(t)),
|
||||
trailing: o.link != null
|
||||
? SizedBox(
|
||||
height: double.maxFinite,
|
||||
child: TextButton(
|
||||
child: Text(L10n.global().detailsTooltip),
|
||||
onPressed: () {
|
||||
launch(o.link!);
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop(o.algorithm);
|
||||
},
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
if (selected == null) {
|
||||
// user canceled
|
||||
return;
|
||||
}
|
||||
_log.info("[call] Selected: ${selected.name}");
|
||||
switch (selected) {
|
||||
case _Algorithm.zeroDce:
|
||||
await ImageProcessor.zeroDce(
|
||||
"${account.url}/${file.path}",
|
||||
file.filename,
|
||||
headers: {
|
||||
"Authorization": Api.getAuthorizationHeaderValue(account),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensurePermission() async {
|
||||
if (platform_k.isAndroid) {
|
||||
if (AndroidInfo().sdkInt < AndroidVersion.R &&
|
||||
!await Permission.hasWriteExternalStorage()) {
|
||||
final results = await requestPermissionsForResult([
|
||||
Permission.WRITE_EXTERNAL_STORAGE,
|
||||
]);
|
||||
if (results[Permission.WRITE_EXTERNAL_STORAGE] !=
|
||||
PermissionRequestResult.granted) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global().errorNoStoragePermission),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
List<_Option> _getOptions() => [
|
||||
if (platform_k.isAndroid)
|
||||
_Option(
|
||||
title: L10n.global().enhanceLowLightTitle,
|
||||
subtitle: "Zero-DCE",
|
||||
link: enhanceZeroDceUrl,
|
||||
algorithm: _Algorithm.zeroDce,
|
||||
),
|
||||
];
|
||||
|
||||
final Account account;
|
||||
final File file;
|
||||
|
||||
static final _log = Logger("widget.handler.enhance_handler.EnhanceHandler");
|
||||
}
|
||||
|
||||
enum _Algorithm {
|
||||
zeroDce,
|
||||
}
|
||||
|
||||
class _Option {
|
||||
const _Option({
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.link,
|
||||
required this.algorithm,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? link;
|
||||
final _Algorithm algorithm;
|
||||
}
|
|
@ -27,6 +27,7 @@ import 'package:nc_photos/widget/album_search_delegate.dart';
|
|||
import 'package:nc_photos/widget/archive_browser.dart';
|
||||
import 'package:nc_photos/widget/builder/album_grid_item_builder.dart';
|
||||
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
||||
import 'package:nc_photos/widget/enhanced_photo_browser.dart';
|
||||
import 'package:nc_photos/widget/fancy_option_picker.dart';
|
||||
import 'package:nc_photos/widget/favorite_browser.dart';
|
||||
import 'package:nc_photos/widget/home_app_bar.dart';
|
||||
|
@ -37,6 +38,7 @@ import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
|
|||
import 'package:nc_photos/widget/selection_app_bar.dart';
|
||||
import 'package:nc_photos/widget/sharing_browser.dart';
|
||||
import 'package:nc_photos/widget/trashbin_browser.dart';
|
||||
import 'package:nc_photos/platform/features.dart' as features;
|
||||
|
||||
class HomeAlbums extends StatefulWidget {
|
||||
const HomeAlbums({
|
||||
|
@ -285,6 +287,19 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
|||
);
|
||||
}
|
||||
|
||||
SelectableItem _buildEnhancedPhotosItem(BuildContext context) {
|
||||
return _ButtonListItem(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
label: L10n.global().collectionEnhancedPhotosLabel,
|
||||
onTap: () {
|
||||
if (!isSelectionMode) {
|
||||
Navigator.of(context).pushNamed(EnhancedPhotoBrowser.routeName,
|
||||
arguments: const EnhancedPhotoBrowserArguments(null));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SelectableItem _buildNewAlbumItem(BuildContext context) {
|
||||
return _ButtonListItem(
|
||||
icon: Icons.add,
|
||||
|
@ -483,6 +498,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
|||
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
|
||||
_buildPersonItem(context),
|
||||
_buildSharingItem(context),
|
||||
if (features.isSupportEnhancement) _buildEnhancedPhotosItem(context),
|
||||
_buildArchiveItem(context),
|
||||
_buildTrashbinItem(context),
|
||||
_buildNewAlbumItem(context),
|
||||
|
|
|
@ -6,10 +6,75 @@ import 'package:nc_photos/account.dart';
|
|||
import 'package:nc_photos/api/api.dart';
|
||||
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||
import 'package:nc_photos/cache_manager_util.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file.dart' as app;
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
|
||||
import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod;
|
||||
|
||||
class LocalImageViewer extends StatefulWidget {
|
||||
const LocalImageViewer({
|
||||
Key? key,
|
||||
required this.file,
|
||||
required this.canZoom,
|
||||
this.onLoaded,
|
||||
this.onHeightChanged,
|
||||
this.onZoomStarted,
|
||||
this.onZoomEnded,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
createState() => _LocalImageViewerState();
|
||||
|
||||
final LocalFile file;
|
||||
final bool canZoom;
|
||||
final VoidCallback? onLoaded;
|
||||
final ValueChanged<double>? onHeightChanged;
|
||||
final VoidCallback? onZoomStarted;
|
||||
final VoidCallback? onZoomEnded;
|
||||
}
|
||||
|
||||
class _LocalImageViewerState extends State<LocalImageViewer> {
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
final ImageProvider provider;
|
||||
if (widget.file is LocalUriFile) {
|
||||
provider = ContentUriImage((widget.file as LocalUriFile).uri);
|
||||
} else {
|
||||
throw ArgumentError("Invalid file");
|
||||
}
|
||||
|
||||
return _ImageViewer(
|
||||
canZoom: widget.canZoom,
|
||||
onHeightChanged: widget.onHeightChanged,
|
||||
onZoomStarted: widget.onZoomStarted,
|
||||
onZoomEnded: widget.onZoomEnded,
|
||||
child: Image(
|
||||
image: provider,
|
||||
fit: BoxFit.contain,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
||||
_onItemLoaded();
|
||||
});
|
||||
return child;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onItemLoaded() {
|
||||
if (!_isLoaded) {
|
||||
_log.info("[_onItemLoaded] ${widget.file.logTag}");
|
||||
_isLoaded = true;
|
||||
widget.onLoaded?.call();
|
||||
}
|
||||
}
|
||||
|
||||
var _isLoaded = false;
|
||||
|
||||
static final _log = Logger("widget.image_viewer._LocalImageViewerState");
|
||||
}
|
||||
|
||||
class RemoteImageViewer extends StatefulWidget {
|
||||
const RemoteImageViewer({
|
||||
Key? key,
|
||||
|
@ -25,7 +90,7 @@ class RemoteImageViewer extends StatefulWidget {
|
|||
@override
|
||||
createState() => _RemoteImageViewerState();
|
||||
|
||||
static void preloadImage(Account account, File file) {
|
||||
static void preloadImage(Account account, app.File file) {
|
||||
LargeImageCacheManager.inst.getFileStream(
|
||||
_getImageUrl(account, file),
|
||||
headers: {
|
||||
|
@ -35,7 +100,7 @@ class RemoteImageViewer extends StatefulWidget {
|
|||
}
|
||||
|
||||
final Account account;
|
||||
final File file;
|
||||
final app.File file;
|
||||
final bool canZoom;
|
||||
final VoidCallback? onLoaded;
|
||||
final ValueChanged<double>? onHeightChanged;
|
||||
|
@ -264,7 +329,7 @@ class _ImageViewerState extends State<_ImageViewer>
|
|||
static final _log = Logger("widget.image_viewer._ImageViewerState");
|
||||
}
|
||||
|
||||
String _getImageUrl(Account account, File file) {
|
||||
String _getImageUrl(Account account, app.File file) {
|
||||
if (file.contentType == "image/gif") {
|
||||
return api_util.getFileUrl(account, file);
|
||||
} else {
|
||||
|
|
232
app/lib/widget/local_file_viewer.dart
Normal file
|
@ -0,0 +1,232 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/share_handler.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart';
|
||||
import 'package:nc_photos/widget/horizontal_page_viewer.dart';
|
||||
import 'package:nc_photos/widget/image_viewer.dart';
|
||||
|
||||
class LocalFileViewerArguments {
|
||||
LocalFileViewerArguments(this.streamFiles, this.startIndex);
|
||||
|
||||
final List<LocalFile> streamFiles;
|
||||
final int startIndex;
|
||||
}
|
||||
|
||||
class LocalFileViewer extends StatefulWidget {
|
||||
static const routeName = "/local-file-viewer";
|
||||
|
||||
static Route buildRoute(LocalFileViewerArguments args) => MaterialPageRoute(
|
||||
builder: (context) => LocalFileViewer.fromArgs(args),
|
||||
);
|
||||
|
||||
const LocalFileViewer({
|
||||
Key? key,
|
||||
required this.streamFiles,
|
||||
required this.startIndex,
|
||||
}) : super(key: key);
|
||||
|
||||
LocalFileViewer.fromArgs(LocalFileViewerArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
streamFiles: args.streamFiles,
|
||||
startIndex: args.startIndex,
|
||||
);
|
||||
|
||||
@override
|
||||
createState() => _LocalFileViewerState();
|
||||
|
||||
final List<LocalFile> streamFiles;
|
||||
final int startIndex;
|
||||
}
|
||||
|
||||
class _LocalFileViewerState extends State<LocalFileViewer> {
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return AppTheme(
|
||||
child: Scaffold(
|
||||
body: Builder(
|
||||
builder: _buildContent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isShowVideoControl = !_isShowVideoControl;
|
||||
});
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(color: Colors.black),
|
||||
if (!_isViewerLoaded ||
|
||||
!_pageStates[_viewerController.currentPage]!.hasLoaded)
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
HorizontalPageViewer(
|
||||
pageCount: widget.streamFiles.length,
|
||||
pageBuilder: _buildPage,
|
||||
initialPage: widget.startIndex,
|
||||
controller: _viewerController,
|
||||
viewportFraction: _viewportFraction,
|
||||
canSwitchPage: _canSwitchPage,
|
||||
),
|
||||
_buildAppBar(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
return Wrap(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
// + status bar height
|
||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(0, -1),
|
||||
end: Alignment(0, 1),
|
||||
colors: [
|
||||
Color.fromARGB(192, 0, 0, 0),
|
||||
Color.fromARGB(0, 0, 0, 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
foregroundColor: Colors.white.withOpacity(.87),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: L10n.global().shareTooltip,
|
||||
onPressed: () {
|
||||
_onSharePressed(context);
|
||||
},
|
||||
),
|
||||
PopupMenuButton<_AppBarMenuOption>(
|
||||
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _AppBarMenuOption.delete,
|
||||
child: Text(L10n.global().deletePermanentlyTooltip),
|
||||
),
|
||||
],
|
||||
onSelected: (option) => _onMenuSelected(context, option),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSharePressed(BuildContext context) async {
|
||||
final file = widget.streamFiles[_viewerController.currentPage];
|
||||
_log.info("[_onSharePressed] Sharing file: ${file.logTag}");
|
||||
await ShareHandler(context: context).shareLocalFiles([file]);
|
||||
}
|
||||
|
||||
void _onMenuSelected(BuildContext context, _AppBarMenuOption option) {
|
||||
switch (option) {
|
||||
case _AppBarMenuOption.delete:
|
||||
_onDeletePressed(context);
|
||||
break;
|
||||
default:
|
||||
_log.shout("[_onMenuSelected] Unknown option: $option");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeletePressed(BuildContext context) async {
|
||||
final file = widget.streamFiles[_viewerController.currentPage];
|
||||
_log.info("[_onDeletePressed] Deleting file: ${file.logTag}");
|
||||
final count = await const DeleteLocalSelectionHandler()(
|
||||
selectedFiles: [file],
|
||||
isRemoveOpened: true,
|
||||
);
|
||||
if (count > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPage(BuildContext context, int index) {
|
||||
if (_pageStates[index] == null) {
|
||||
_pageStates[index] = _PageState();
|
||||
}
|
||||
return FractionallySizedBox(
|
||||
widthFactor: 1 / _viewportFraction,
|
||||
child: _buildItemView(context, index),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemView(BuildContext context, int index) {
|
||||
final file = widget.streamFiles[index];
|
||||
if (file_util.isSupportedImageMime(file.mime ?? "")) {
|
||||
return _buildImageView(context, index);
|
||||
} else {
|
||||
_log.shout("[_buildItemView] Unknown file format: ${file.mime}");
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildImageView(BuildContext context, int index) => LocalImageViewer(
|
||||
file: widget.streamFiles[index],
|
||||
canZoom: true,
|
||||
onLoaded: () => _onImageLoaded(index),
|
||||
onZoomStarted: () {
|
||||
setState(() {
|
||||
_isZoomed = true;
|
||||
});
|
||||
},
|
||||
onZoomEnded: () {
|
||||
setState(() {
|
||||
_isZoomed = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
void _onImageLoaded(int index) {
|
||||
if (_viewerController.currentPage == index &&
|
||||
!_pageStates[index]!.hasLoaded) {
|
||||
setState(() {
|
||||
_pageStates[index]!.hasLoaded = true;
|
||||
_isViewerLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSwitchPage => !_isZoomed;
|
||||
|
||||
var _isShowVideoControl = true;
|
||||
var _isZoomed = false;
|
||||
|
||||
final _viewerController = HorizontalPageViewerController();
|
||||
bool _isViewerLoaded = false;
|
||||
final _pageStates = <int, _PageState>{};
|
||||
|
||||
static final _log = Logger("widget.local_file_viewer._LocalFileViewerState");
|
||||
|
||||
static const _viewportFraction = 1.05;
|
||||
}
|
||||
|
||||
class _PageState {
|
||||
bool hasLoaded = false;
|
||||
}
|
||||
|
||||
enum _AppBarMenuOption {
|
||||
delete,
|
||||
}
|
|
@ -16,8 +16,10 @@ import 'package:nc_photos/widget/album_share_outlier_browser.dart';
|
|||
import 'package:nc_photos/widget/archive_browser.dart';
|
||||
import 'package:nc_photos/widget/connect.dart';
|
||||
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
||||
import 'package:nc_photos/widget/enhanced_photo_browser.dart';
|
||||
import 'package:nc_photos/widget/favorite_browser.dart';
|
||||
import 'package:nc_photos/widget/home.dart';
|
||||
import 'package:nc_photos/widget/local_file_viewer.dart';
|
||||
import 'package:nc_photos/widget/people_browser.dart';
|
||||
import 'package:nc_photos/widget/person_browser.dart';
|
||||
import 'package:nc_photos/widget/root_picker.dart';
|
||||
|
@ -168,6 +170,8 @@ class _MyAppState extends State<MyApp>
|
|||
route ??= _handleAlbumPickerRoute(settings);
|
||||
route ??= _handleSmartAlbumBrowserRoute(settings);
|
||||
route ??= _handleFavoriteBrowserRoute(settings);
|
||||
route ??= _handleEnhancedPhotoBrowserRoute(settings);
|
||||
route ??= _handleLocalFileViewerRoute(settings);
|
||||
return route;
|
||||
}
|
||||
|
||||
|
@ -498,6 +502,40 @@ class _MyAppState extends State<MyApp>
|
|||
return null;
|
||||
}
|
||||
|
||||
Route<dynamic>? _handleEnhancedPhotoBrowserRoute(RouteSettings settings) {
|
||||
try {
|
||||
if (settings.name == EnhancedPhotoBrowser.routeName &&
|
||||
settings.arguments != null) {
|
||||
final args = settings.arguments as EnhancedPhotoBrowserArguments;
|
||||
return EnhancedPhotoBrowser.buildRoute(args);
|
||||
} else if (settings.name
|
||||
?.startsWith("${EnhancedPhotoBrowser.routeName}?") ==
|
||||
true) {
|
||||
final queries = Uri.parse(settings.name!).queryParameters;
|
||||
final args = EnhancedPhotoBrowserArguments(queries["filename"]);
|
||||
return EnhancedPhotoBrowser.buildRoute(args);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"[_handleEnhancedPhotoBrowserRoute] Failed while handling route", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Route<dynamic>? _handleLocalFileViewerRoute(RouteSettings settings) {
|
||||
try {
|
||||
if (settings.name == LocalFileViewer.routeName &&
|
||||
settings.arguments != null) {
|
||||
final args = settings.arguments as LocalFileViewerArguments;
|
||||
return LocalFileViewer.buildRoute(args);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"[_handleLocalFileViewerRoute] Failed while handling route", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
|
|
@ -7,6 +7,8 @@ import 'package:nc_photos/app_localizations.dart';
|
|||
import 'package:nc_photos/changelog.dart' as changelog;
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/mobile/android/activity.dart';
|
||||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
|
@ -80,18 +82,23 @@ class _SplashState extends State<Splash> {
|
|||
);
|
||||
}
|
||||
|
||||
void _initTimedExit() {
|
||||
Future.delayed(const Duration(seconds: 1)).then((_) {
|
||||
final account = Pref().getCurrentAccount();
|
||||
if (isNeedSetup()) {
|
||||
Navigator.pushReplacementNamed(context, Setup.routeName);
|
||||
} else if (account == null) {
|
||||
Navigator.pushReplacementNamed(context, SignIn.routeName);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Home.routeName,
|
||||
arguments: HomeArguments(account));
|
||||
Future<void> _initTimedExit() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final account = Pref().getCurrentAccount();
|
||||
if (isNeedSetup()) {
|
||||
Navigator.pushReplacementNamed(context, Setup.routeName);
|
||||
} else if (account == null) {
|
||||
Navigator.pushReplacementNamed(context, SignIn.routeName);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Home.routeName,
|
||||
arguments: HomeArguments(account));
|
||||
if (platform_k.isAndroid) {
|
||||
final initialRoute = await Activity.consumeInitialRoute();
|
||||
if (initialRoute != null) {
|
||||
Navigator.pushNamed(context, initialRoute);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldUpgrade() {
|
||||
|
|
|
@ -16,12 +16,14 @@ import 'package:nc_photos/entity/file.dart';
|
|||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/notified_action.dart';
|
||||
import 'package:nc_photos/platform/features.dart' as features;
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/share_handler.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/use_case/update_property.dart';
|
||||
import 'package:nc_photos/widget/animated_visibility.dart';
|
||||
import 'package:nc_photos/widget/disposable.dart';
|
||||
import 'package:nc_photos/widget/handler/enhance_handler.dart';
|
||||
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
|
||||
import 'package:nc_photos/widget/horizontal_page_viewer.dart';
|
||||
import 'package:nc_photos/widget/image_viewer.dart';
|
||||
|
@ -187,6 +189,9 @@ class _ViewerState extends State<Viewer>
|
|||
}
|
||||
|
||||
Widget _buildBottomAppBar(BuildContext context) {
|
||||
final index =
|
||||
_isViewerLoaded ? _viewerController.currentPage : widget.startIndex;
|
||||
final file = widget.streamFiles[index];
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Material(
|
||||
|
@ -206,6 +211,16 @@ class _ViewerState extends State<Viewer>
|
|||
tooltip: L10n.global().shareTooltip,
|
||||
onPressed: () => _onSharePressed(context),
|
||||
),
|
||||
if (features.isSupportEnhancement &&
|
||||
EnhanceHandler.isSupportedFormat(file))
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.auto_fix_high_outlined,
|
||||
color: Colors.white.withOpacity(.87),
|
||||
),
|
||||
tooltip: L10n.global().enhanceTooltip,
|
||||
onPressed: () => _onEnhancePressed(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.download_outlined,
|
||||
|
@ -555,6 +570,20 @@ class _ViewerState extends State<Viewer>
|
|||
).shareFiles(widget.account, [file]);
|
||||
}
|
||||
|
||||
void _onEnhancePressed(BuildContext context) {
|
||||
final file = widget.streamFiles[_viewerController.currentPage];
|
||||
if (!file_util.isSupportedImageFormat(file)) {
|
||||
_log.shout("[_onEnhancePressed] Video file not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
_log.info("[_onEnhancePressed] Enhance file: ${file.path}");
|
||||
EnhanceHandler(
|
||||
account: widget.account,
|
||||
file: file,
|
||||
)(context);
|
||||
}
|
||||
|
||||
void _onDownloadPressed() {
|
||||
final file = widget.streamFiles[_viewerController.currentPage];
|
||||
_log.info("[_onDownloadPressed] Downloading file: ${file.path}");
|
||||
|
|
|
@ -620,7 +620,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.0.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mime
|
||||
url: "https://pub.dartlang.org"
|
||||
|
|
|
@ -71,6 +71,7 @@ dependencies:
|
|||
intl: ^0.17.0
|
||||
kiwi: ^4.0.1
|
||||
logging: ^1.0.1
|
||||
mime: ^1.0.1
|
||||
mutex: ^3.0.0
|
||||
native_device_orientation: ^1.0.0
|
||||
nc_photos_plugin:
|
||||
|
|
|
@ -54,5 +54,7 @@ dependencies {
|
|||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5"
|
||||
implementation "androidx.annotation:annotation:1.3.0"
|
||||
implementation "androidx.core:core-ktx:1.7.0"
|
||||
implementation "androidx.exifinterface:exifinterface:1.3.3"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'org.tensorflow:tensorflow-lite:2.8.0'
|
||||
}
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.nkming.nc_photos.plugin">
|
||||
<manifest package="com.nkming.nc_photos.plugin"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".ImageProcessorService"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
|
||||
fun Bitmap.aspectRatio() = width / height.toFloat()
|
||||
|
||||
enum class BitmapResizeMethod {
|
||||
FIT,
|
||||
FILL,
|
||||
}
|
||||
|
||||
interface BitmapUtil {
|
||||
companion object {
|
||||
fun loadImageFixed(
|
||||
context: Context, uri: Uri, targetW: Int, targetH: Int
|
||||
): Bitmap {
|
||||
val opt = loadImageBounds(context, uri)
|
||||
val subsample = calcBitmapSubsample(
|
||||
opt.outWidth, opt.outHeight, targetW, targetH,
|
||||
BitmapResizeMethod.FILL
|
||||
)
|
||||
if (subsample > 1) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Subsample image to fixed: $subsample ${opt.outWidth}x${opt.outHeight} -> ${targetW}x$targetH"
|
||||
)
|
||||
}
|
||||
val outOpt = BitmapFactory.Options().apply {
|
||||
inSampleSize = subsample
|
||||
}
|
||||
val bitmap = loadImage(context, uri, outOpt)
|
||||
if (subsample > 1) {
|
||||
Log.d(
|
||||
TAG, "Bitmap subsampled: ${bitmap.width}x${bitmap.height}"
|
||||
)
|
||||
}
|
||||
return Bitmap.createScaledBitmap(bitmap, targetW, targetH, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a bitmap
|
||||
*
|
||||
* If @c resizeMethod == FIT, make sure the size of the bitmap can fit
|
||||
* inside the bound defined by @c targetW and @c targetH, i.e.,
|
||||
* bitmap.w <= @c targetW and bitmap.h <= @c targetH
|
||||
*
|
||||
* If @c resizeMethod == FILL, make sure the size of the bitmap can
|
||||
* completely fill the bound defined by @c targetW and @c targetH, i.e.,
|
||||
* bitmap.w >= @c targetW and bitmap.h >= @c targetH
|
||||
*
|
||||
* If bitmap is smaller than the bound and @c shouldUpscale == true, it
|
||||
* will be upscaled
|
||||
*
|
||||
* @param context
|
||||
* @param uri
|
||||
* @param targetW
|
||||
* @param targetH
|
||||
* @param resizeMethod
|
||||
* @param isAllowSwapSide
|
||||
* @param shouldUpscale
|
||||
* @return
|
||||
*/
|
||||
fun loadImage(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
targetW: Int,
|
||||
targetH: Int,
|
||||
resizeMethod: BitmapResizeMethod,
|
||||
isAllowSwapSide: Boolean = false,
|
||||
shouldUpscale: Boolean = true,
|
||||
): Bitmap {
|
||||
val opt = loadImageBounds(context, uri)
|
||||
val shouldSwapSide = isAllowSwapSide &&
|
||||
opt.outWidth != opt.outHeight &&
|
||||
(opt.outWidth >= opt.outHeight) != (targetW >= targetH)
|
||||
val dstW = if (shouldSwapSide) targetH else targetW
|
||||
val dstH = if (shouldSwapSide) targetW else targetH
|
||||
val subsample = calcBitmapSubsample(
|
||||
opt.outWidth, opt.outHeight, dstW, dstH, resizeMethod
|
||||
)
|
||||
if (subsample > 1) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Subsample image to ${resizeMethod.name}: $subsample ${opt.outWidth}x${opt.outHeight} -> ${dstW}x$dstH" +
|
||||
(if (shouldSwapSide) " (swapped)" else "")
|
||||
)
|
||||
}
|
||||
val outOpt = BitmapFactory.Options().apply {
|
||||
inSampleSize = subsample
|
||||
}
|
||||
val bitmap = loadImage(context, uri, outOpt)
|
||||
if (subsample > 1) {
|
||||
Log.d(
|
||||
TAG, "Bitmap subsampled: ${bitmap.width}x${bitmap.height}"
|
||||
)
|
||||
}
|
||||
if (bitmap.width < dstW && bitmap.height < dstH && !shouldUpscale) {
|
||||
return bitmap
|
||||
}
|
||||
return when (resizeMethod) {
|
||||
BitmapResizeMethod.FIT -> Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
minOf(dstW, (dstH * bitmap.aspectRatio()).toInt()),
|
||||
minOf(dstH, (dstW / bitmap.aspectRatio()).toInt()),
|
||||
true
|
||||
)
|
||||
|
||||
BitmapResizeMethod.FILL -> Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
maxOf(dstW, (dstH * bitmap.aspectRatio()).toInt()),
|
||||
maxOf(dstH, (dstW / bitmap.aspectRatio()).toInt()),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImageBounds(
|
||||
context: Context, uri: Uri
|
||||
): BitmapFactory.Options {
|
||||
context.contentResolver.openInputStream(uri)!!.use {
|
||||
val opt = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeStream(it, null, opt)
|
||||
return opt
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImage(
|
||||
context: Context, uri: Uri, opt: BitmapFactory.Options
|
||||
): Bitmap {
|
||||
context.contentResolver.openInputStream(uri)!!.use {
|
||||
return BitmapFactory.decodeStream(it, null, opt)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcBitmapSubsample(
|
||||
originalW: Int,
|
||||
originalH: Int,
|
||||
targetW: Int,
|
||||
targetH: Int,
|
||||
resizeMethod: BitmapResizeMethod
|
||||
): Int {
|
||||
return when (resizeMethod) {
|
||||
BitmapResizeMethod.FIT -> maxOf(
|
||||
originalW / targetW,
|
||||
originalH / targetH
|
||||
)
|
||||
BitmapResizeMethod.FILL -> minOf(
|
||||
originalW / targetW,
|
||||
originalH / targetH
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BitmapUtil"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class ContentUriChannelHandler(context: Context) :
|
||||
MethodChannel.MethodCallHandler {
|
||||
companion object {
|
||||
const val METHOD_CHANNEL = "${K.LIB_ID}/content_uri_method"
|
||||
|
||||
private const val TAG = "ContentUriChannelHandler"
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"readUri" -> {
|
||||
try {
|
||||
readUri(call.argument("uri")!!, result)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.toString(), null)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUri(uri: String, result: MethodChannel.Result) {
|
||||
val uriTyped = Uri.parse(uri)
|
||||
try {
|
||||
val bytes =
|
||||
context.contentResolver.openInputStream(uriTyped)!!.use {
|
||||
it.readBytes()
|
||||
}
|
||||
result.success(bytes)
|
||||
} catch (e: FileNotFoundException) {
|
||||
result.error("fileNotFoundException", e.toString(), null)
|
||||
}
|
||||
}
|
||||
|
||||
private val context = context
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
interface MessageEvent
|
||||
|
||||
data class ImageProcessorCompletedEvent(
|
||||
val result: Uri,
|
||||
) : MessageEvent
|
||||
|
||||
data class ImageProcessorFailedEvent(
|
||||
val exception: Throwable,
|
||||
) : MessageEvent
|
|
@ -1,3 +1,5 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
class PermissionException(message: String) : Exception(message)
|
||||
|
||||
class HttpException(statusCode: Int, message: String): Exception(message)
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class ImageProcessorChannelHandler(context: Context) :
|
||||
MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||
companion object {
|
||||
const val METHOD_CHANNEL = "${K.LIB_ID}/image_processor_method"
|
||||
|
||||
private const val TAG = "ImageProcessorChannelHandler"
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"zeroDce" -> {
|
||||
try {
|
||||
zeroDce(
|
||||
call.argument("fileUrl")!!,
|
||||
call.argument("headers"),
|
||||
call.argument("filename")!!,
|
||||
result
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.toString(), null)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
eventSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
private fun zeroDce(
|
||||
fileUrl: String, headers: Map<String, String>?, filename: String,
|
||||
result: MethodChannel.Result
|
||||
) {
|
||||
val intent = Intent(context, ImageProcessorService::class.java).apply {
|
||||
putExtra(
|
||||
ImageProcessorService.EXTRA_METHOD,
|
||||
ImageProcessorService.METHOD_ZERO_DCE
|
||||
)
|
||||
putExtra(ImageProcessorService.EXTRA_FILE_URL, fileUrl)
|
||||
putExtra(
|
||||
ImageProcessorService.EXTRA_HEADERS,
|
||||
headers?.let { HashMap(it) })
|
||||
putExtra(ImageProcessorService.EXTRA_FILENAME, filename)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private val context = context
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
}
|
|
@ -0,0 +1,501 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.AsyncTask
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.nkming.nc_photos.plugin.image_processor.ZeroDce
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageProcessorService : Service() {
|
||||
companion object {
|
||||
const val EXTRA_METHOD = "method"
|
||||
const val METHOD_ZERO_DCE = "zero-dce"
|
||||
const val EXTRA_FILE_URL = "fileUrl"
|
||||
const val EXTRA_HEADERS = "headers"
|
||||
const val EXTRA_FILENAME = "filename"
|
||||
|
||||
private const val ACTION_CANCEL = "cancel"
|
||||
|
||||
private const val NOTIFICATION_ID =
|
||||
K.IMAGE_PROCESSOR_SERVICE_NOTIFICATION_ID
|
||||
private const val RESULT_NOTIFICATION_ID =
|
||||
K.IMAGE_PROCESSOR_SERVICE_RESULT_NOTIFICATION_ID
|
||||
private const val RESULT_FAILED_NOTIFICATION_ID =
|
||||
K.IMAGE_PROCESSOR_SERVICE_RESULT_FAILED_NOTIFICATION_ID
|
||||
private const val CHANNEL_ID = "ImageProcessorService"
|
||||
|
||||
const val TAG = "ImageProcessorService"
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
override fun onCreate() {
|
||||
Log.i(TAG, "[onCreate] Service created")
|
||||
super.onCreate()
|
||||
wakeLock.acquire()
|
||||
createNotificationChannel()
|
||||
cleanUp()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "[onDestroy] Service destroyed")
|
||||
wakeLock.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
when (intent.action) {
|
||||
ACTION_CANCEL -> onCancel(startId)
|
||||
else -> onNewImage(intent, startId)
|
||||
}
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun onCancel(startId: Int) {
|
||||
Log.i(TAG, "[onCancel] Cancel requested")
|
||||
cmdTask?.cancel(false)
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
private fun onNewImage(intent: Intent, startId: Int) {
|
||||
assert(intent.hasExtra(EXTRA_METHOD))
|
||||
assert(intent.hasExtra(EXTRA_FILE_URL))
|
||||
if (!isForeground) {
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
isForeground = true
|
||||
} catch (e: Throwable) {
|
||||
// ???
|
||||
Log.e(TAG, "[onStartCommand] Failed while startForeground", e)
|
||||
}
|
||||
}
|
||||
|
||||
val method = intent.getStringExtra(EXTRA_METHOD)
|
||||
when (method) {
|
||||
METHOD_ZERO_DCE -> onZeroDce(startId, intent.extras!!)
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown method: $method")
|
||||
// we can't call stopSelf here as it'll stop the service even if
|
||||
// there are commands running in the bg
|
||||
addCommand(ImageProcessorCommand(startId, "null", "", null, ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onZeroDce(startId: Int, extras: Bundle) {
|
||||
val fileUrl = extras.getString(EXTRA_FILE_URL)!!
|
||||
|
||||
@Suppress("Unchecked_cast")
|
||||
val headers =
|
||||
extras.getSerializable(EXTRA_HEADERS) as HashMap<String, String>?
|
||||
val filename = extras.getString(EXTRA_FILENAME)!!
|
||||
addCommand(
|
||||
ImageProcessorCommand(
|
||||
startId, METHOD_ZERO_DCE, fileUrl, headers, filename
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannelCompat.Builder(
|
||||
CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW
|
||||
).run {
|
||||
setName("Image processing")
|
||||
setDescription("Enhance images in the background")
|
||||
build()
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(content: String? = null): Notification {
|
||||
val cancelIntent =
|
||||
Intent(this, ImageProcessorService::class.java).apply {
|
||||
action = ACTION_CANCEL
|
||||
}
|
||||
val cancelPendingIntent = PendingIntent.getService(
|
||||
this, 0, cancelIntent, getPendingIntentFlagImmutable()
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||
setSmallIcon(R.drawable.outline_auto_fix_high_white_24)
|
||||
setContentTitle("Processing image")
|
||||
if (content != null) setContentText(content)
|
||||
addAction(
|
||||
0, getString(android.R.string.cancel), cancelPendingIntent
|
||||
)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildResultNotification(result: Uri): Notification {
|
||||
val intent = Intent().apply {
|
||||
`package` = packageName
|
||||
component = ComponentName(
|
||||
"com.nkming.nc_photos", "com.nkming.nc_photos.MainActivity"
|
||||
)
|
||||
action = K.ACTION_SHOW_IMAGE_PROCESSOR_RESULT
|
||||
putExtra(K.EXTRA_IMAGE_RESULT_URI, result)
|
||||
}
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or getPendingIntentFlagImmutable()
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||
setSmallIcon(R.drawable.outline_image_white_24)
|
||||
setContentTitle("Successfully enhanced image")
|
||||
setContentText("Tap to view the result")
|
||||
setContentIntent(pi)
|
||||
setAutoCancel(true)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildResultFailedNotification(
|
||||
exception: Throwable
|
||||
): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||
setSmallIcon(R.drawable.outline_image_white_24)
|
||||
setContentTitle("Failed enhancing image")
|
||||
setContentText(exception.message)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCommand(cmd: ImageProcessorCommand) {
|
||||
cmds.add(cmd)
|
||||
if (cmdTask == null) {
|
||||
runCommand()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private fun runCommand() {
|
||||
val cmd = cmds.first()
|
||||
notificationManager.notify(
|
||||
NOTIFICATION_ID, buildNotification(cmd.filename)
|
||||
)
|
||||
cmdTask = object : ImageProcessorCommandTask(applicationContext) {
|
||||
override fun onPostExecute(result: MessageEvent) {
|
||||
notifyResult(result)
|
||||
cmds.removeFirst()
|
||||
stopSelf(cmd.startId)
|
||||
@Suppress("Deprecation")
|
||||
if (cmds.isNotEmpty() && !isCancelled) {
|
||||
runCommand()
|
||||
} else {
|
||||
cmdTask = null
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
@Suppress("Deprecation")
|
||||
executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyResult(event: MessageEvent) {
|
||||
if (event is ImageProcessorCompletedEvent) {
|
||||
notificationManager.notify(
|
||||
RESULT_NOTIFICATION_ID, buildResultNotification(event.result)
|
||||
)
|
||||
} else if (event is ImageProcessorFailedEvent) {
|
||||
notificationManager.notify(
|
||||
RESULT_FAILED_NOTIFICATION_ID,
|
||||
buildResultFailedNotification(event.exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temp files in case the service ended prematurely last time
|
||||
*/
|
||||
private fun cleanUp() {
|
||||
try {
|
||||
getTempDir(this).deleteRecursively()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "[cleanUp] Failed while cleanUp", e)
|
||||
}
|
||||
}
|
||||
|
||||
private var isForeground = false
|
||||
private val cmds = mutableListOf<ImageProcessorCommand>()
|
||||
private var cmdTask: ImageProcessorCommandTask? = null
|
||||
|
||||
private val notificationManager by lazy {
|
||||
NotificationManagerCompat.from(this)
|
||||
}
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "nc-photos:ImageProcessorService"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ImageProcessorCommand(
|
||||
val startId: Int,
|
||||
val method: String,
|
||||
val fileUrl: String,
|
||||
val headers: Map<String, String>?,
|
||||
val filename: String,
|
||||
val args: Map<String, Any> = mapOf(),
|
||||
)
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private open class ImageProcessorCommandTask(context: Context) :
|
||||
AsyncTask<ImageProcessorCommand, Unit, MessageEvent>() {
|
||||
companion object {
|
||||
private val exifTagOfInterests = listOf(
|
||||
ExifInterface.TAG_IMAGE_DESCRIPTION,
|
||||
ExifInterface.TAG_MAKE,
|
||||
ExifInterface.TAG_MODEL,
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.TAG_X_RESOLUTION,
|
||||
ExifInterface.TAG_Y_RESOLUTION,
|
||||
ExifInterface.TAG_DATETIME,
|
||||
ExifInterface.TAG_ARTIST,
|
||||
ExifInterface.TAG_COPYRIGHT,
|
||||
ExifInterface.TAG_EXPOSURE_TIME,
|
||||
ExifInterface.TAG_F_NUMBER,
|
||||
ExifInterface.TAG_EXPOSURE_PROGRAM,
|
||||
ExifInterface.TAG_SPECTRAL_SENSITIVITY,
|
||||
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY,
|
||||
ExifInterface.TAG_OECF,
|
||||
ExifInterface.TAG_SENSITIVITY_TYPE,
|
||||
ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY,
|
||||
ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX,
|
||||
ExifInterface.TAG_ISO_SPEED,
|
||||
ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY,
|
||||
ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ,
|
||||
ExifInterface.TAG_EXIF_VERSION,
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL,
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED,
|
||||
ExifInterface.TAG_OFFSET_TIME,
|
||||
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
|
||||
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
|
||||
ExifInterface.TAG_SHUTTER_SPEED_VALUE,
|
||||
ExifInterface.TAG_APERTURE_VALUE,
|
||||
ExifInterface.TAG_BRIGHTNESS_VALUE,
|
||||
ExifInterface.TAG_EXPOSURE_BIAS_VALUE,
|
||||
ExifInterface.TAG_MAX_APERTURE_VALUE,
|
||||
ExifInterface.TAG_SUBJECT_DISTANCE,
|
||||
ExifInterface.TAG_METERING_MODE,
|
||||
ExifInterface.TAG_LIGHT_SOURCE,
|
||||
ExifInterface.TAG_FLASH,
|
||||
ExifInterface.TAG_FOCAL_LENGTH,
|
||||
ExifInterface.TAG_SUBJECT_AREA,
|
||||
ExifInterface.TAG_MAKER_NOTE,
|
||||
ExifInterface.TAG_USER_COMMENT,
|
||||
ExifInterface.TAG_SUBSEC_TIME,
|
||||
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL,
|
||||
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED,
|
||||
ExifInterface.TAG_FLASHPIX_VERSION,
|
||||
ExifInterface.TAG_FLASH_ENERGY,
|
||||
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE,
|
||||
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION,
|
||||
ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION,
|
||||
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT,
|
||||
ExifInterface.TAG_SUBJECT_LOCATION,
|
||||
ExifInterface.TAG_EXPOSURE_INDEX,
|
||||
ExifInterface.TAG_SENSING_METHOD,
|
||||
ExifInterface.TAG_FILE_SOURCE,
|
||||
ExifInterface.TAG_SCENE_TYPE,
|
||||
ExifInterface.TAG_CFA_PATTERN,
|
||||
ExifInterface.TAG_CUSTOM_RENDERED,
|
||||
ExifInterface.TAG_EXPOSURE_MODE,
|
||||
ExifInterface.TAG_WHITE_BALANCE,
|
||||
ExifInterface.TAG_DIGITAL_ZOOM_RATIO,
|
||||
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM,
|
||||
ExifInterface.TAG_SCENE_CAPTURE_TYPE,
|
||||
ExifInterface.TAG_GAIN_CONTROL,
|
||||
ExifInterface.TAG_CONTRAST,
|
||||
ExifInterface.TAG_SATURATION,
|
||||
ExifInterface.TAG_SHARPNESS,
|
||||
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION,
|
||||
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE,
|
||||
ExifInterface.TAG_IMAGE_UNIQUE_ID,
|
||||
ExifInterface.TAG_CAMERA_OWNER_NAME,
|
||||
ExifInterface.TAG_BODY_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_SPECIFICATION,
|
||||
ExifInterface.TAG_LENS_MAKE,
|
||||
ExifInterface.TAG_LENS_MODEL,
|
||||
ExifInterface.TAG_GAMMA,
|
||||
ExifInterface.TAG_GPS_VERSION_ID,
|
||||
ExifInterface.TAG_GPS_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_LATITUDE,
|
||||
ExifInterface.TAG_GPS_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_ALTITUDE_REF,
|
||||
ExifInterface.TAG_GPS_ALTITUDE,
|
||||
ExifInterface.TAG_GPS_TIMESTAMP,
|
||||
ExifInterface.TAG_GPS_SATELLITES,
|
||||
ExifInterface.TAG_GPS_STATUS,
|
||||
ExifInterface.TAG_GPS_MEASURE_MODE,
|
||||
ExifInterface.TAG_GPS_DOP,
|
||||
ExifInterface.TAG_GPS_SPEED_REF,
|
||||
ExifInterface.TAG_GPS_SPEED,
|
||||
ExifInterface.TAG_GPS_TRACK_REF,
|
||||
ExifInterface.TAG_GPS_TRACK,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION,
|
||||
ExifInterface.TAG_GPS_MAP_DATUM,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING_REF,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE,
|
||||
ExifInterface.TAG_GPS_PROCESSING_METHOD,
|
||||
ExifInterface.TAG_GPS_AREA_INFORMATION,
|
||||
ExifInterface.TAG_GPS_DATESTAMP,
|
||||
ExifInterface.TAG_GPS_DIFFERENTIAL,
|
||||
ExifInterface.TAG_GPS_H_POSITIONING_ERROR,
|
||||
)
|
||||
|
||||
private const val TAG = "ImageProcessorCommandTask"
|
||||
}
|
||||
|
||||
override fun doInBackground(
|
||||
vararg params: ImageProcessorCommand?
|
||||
): MessageEvent {
|
||||
val cmd = params[0]!!
|
||||
return try {
|
||||
val outUri = handleCommand(cmd)
|
||||
ImageProcessorCompletedEvent(outUri)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "[doInBackground] Failed while handleCommand", e)
|
||||
ImageProcessorFailedEvent(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCommand(cmd: ImageProcessorCommand): Uri {
|
||||
val file = downloadFile(cmd.fileUrl, cmd.headers)
|
||||
handleCancel()
|
||||
return try {
|
||||
val fileUri = Uri.fromFile(file)
|
||||
val output = when (cmd.method) {
|
||||
ImageProcessorService.METHOD_ZERO_DCE -> ZeroDce(context).infer(
|
||||
fileUri
|
||||
)
|
||||
else -> throw IllegalArgumentException(
|
||||
"Unknown method: ${cmd.method}"
|
||||
)
|
||||
}
|
||||
handleCancel()
|
||||
saveBitmap(output, cmd.filename, file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFile(
|
||||
fileUrl: String, headers: Map<String, String>?
|
||||
): File {
|
||||
Log.i(TAG, "[downloadFile] $fileUrl")
|
||||
return (URL(fileUrl).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 8000
|
||||
readTimeout = 15000
|
||||
for (entry in (headers ?: mapOf()).entries) {
|
||||
setRequestProperty(entry.key, entry.value)
|
||||
}
|
||||
}.use {
|
||||
val responseCode = it.responseCode
|
||||
if (responseCode / 100 == 2) {
|
||||
val file =
|
||||
File.createTempFile("img", null, getTempDir(context))
|
||||
file.outputStream().use { oStream ->
|
||||
it.inputStream.copyTo(oStream)
|
||||
}
|
||||
file
|
||||
} else {
|
||||
Log.e(
|
||||
TAG,
|
||||
"[downloadFile] Failed downloading file: HTTP$responseCode"
|
||||
)
|
||||
throw HttpException(
|
||||
responseCode, "Failed downloading file (HTTP$responseCode)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBitmap(
|
||||
bitmap: Bitmap, filename: String, srcFile: File
|
||||
): Uri {
|
||||
val outFile = File.createTempFile("out", null, getTempDir(context))
|
||||
outFile.outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it)
|
||||
}
|
||||
|
||||
// then copy the EXIF tags
|
||||
try {
|
||||
val iExif = ExifInterface(srcFile)
|
||||
val oExif = ExifInterface(outFile)
|
||||
copyExif(iExif, oExif)
|
||||
oExif.saveAttributes()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "[copyExif] Failed while saving EXIF", e)
|
||||
}
|
||||
|
||||
// move file to user accessible storage
|
||||
val uri = MediaStoreUtil.copyFileToDownload(
|
||||
context, Uri.fromFile(outFile), filename,
|
||||
"Photos (for Nextcloud)/Enhanced Photos"
|
||||
)
|
||||
outFile.delete()
|
||||
return uri
|
||||
}
|
||||
|
||||
private fun copyExif(from: ExifInterface, to: ExifInterface) {
|
||||
// only a subset will be copied over
|
||||
for (t in exifTagOfInterests) {
|
||||
try {
|
||||
from.getAttribute(t)?.let { to.setAttribute(t, it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "[copyExif] Failed while copying tag: $t", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCancel() {
|
||||
if (isCancelled) {
|
||||
Log.i(TAG, "[handleCancel] Canceled")
|
||||
throw InterruptedException()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private val context = context
|
||||
}
|
||||
|
||||
private fun getTempDir(context: Context): File {
|
||||
val f = File(context.cacheDir, "imageProcessor")
|
||||
if (!f.exists()) {
|
||||
f.mkdirs()
|
||||
} else if (!f.isDirectory) {
|
||||
f.delete()
|
||||
f.mkdirs()
|
||||
}
|
||||
return f
|
||||
}
|
|
@ -4,11 +4,20 @@ interface K {
|
|||
companion object {
|
||||
const val DOWNLOAD_NOTIFICATION_ID_MIN = 1000
|
||||
const val DOWNLOAD_NOTIFICATION_ID_MAX = 2000
|
||||
const val IMAGE_PROCESSOR_SERVICE_NOTIFICATION_ID = 5000
|
||||
const val IMAGE_PROCESSOR_SERVICE_RESULT_NOTIFICATION_ID = 5001
|
||||
const val IMAGE_PROCESSOR_SERVICE_RESULT_FAILED_NOTIFICATION_ID = 5002
|
||||
|
||||
const val PERMISSION_REQUEST_CODE = 11011
|
||||
const val MEDIA_STORE_DELETE_REQUEST_CODE = 11012
|
||||
|
||||
const val LIB_ID = "com.nkming.nc_photos.plugin"
|
||||
|
||||
const val ACTION_DOWNLOAD_CANCEL = "${LIB_ID}.ACTION_DOWNLOAD_CANCEL"
|
||||
const val ACTION_SHOW_IMAGE_PROCESSOR_RESULT =
|
||||
"${LIB_ID}.ACTION_SHOW_IMAGE_PROCESSOR_RESULT"
|
||||
|
||||
const val EXTRA_NOTIFICATION_ID = "${LIB_ID}.EXTRA_NOTIFICATION_ID"
|
||||
const val EXTRA_IMAGE_RESULT_URI = "${LIB_ID}.EXTRA_IMAGE_RESULT_URI"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,279 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.io.File
|
||||
|
||||
/*
|
||||
* Save downloaded item on device
|
||||
*
|
||||
* Methods:
|
||||
* Write binary content to a file in the Download directory. Return the Uri to
|
||||
* the file
|
||||
* fun saveFileToDownload(content: ByteArray, filename: String, subDir: String?): String
|
||||
*
|
||||
* Return files under @c relativePath and its sub dirs
|
||||
* fun queryFiles(relativePath: String): List<Map>
|
||||
*/
|
||||
class MediaStoreChannelHandler(context: Context) :
|
||||
MethodChannel.MethodCallHandler, EventChannel.StreamHandler,
|
||||
ActivityAware, PluginRegistry.ActivityResultListener {
|
||||
companion object {
|
||||
const val EVENT_CHANNEL = "${K.LIB_ID}/media_store"
|
||||
const val METHOD_CHANNEL = "${K.LIB_ID}/media_store_method"
|
||||
|
||||
private const val TAG = "MediaStoreChannelHandler"
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(
|
||||
binding: ActivityPluginBinding
|
||||
) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(
|
||||
requestCode: Int, resultCode: Int, data: Intent?
|
||||
): Boolean {
|
||||
if (requestCode == K.MEDIA_STORE_DELETE_REQUEST_CODE) {
|
||||
eventSink?.success(buildMap {
|
||||
put("event", "DeleteRequestResult")
|
||||
put("resultCode", resultCode)
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
eventSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"saveFileToDownload" -> {
|
||||
try {
|
||||
saveFileToDownload(
|
||||
call.argument("content")!!, call.argument("filename")!!,
|
||||
call.argument("subDir"), result
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
"copyFileToDownload" -> {
|
||||
try {
|
||||
copyFileToDownload(
|
||||
call.argument("fromFile")!!, call.argument("filename"),
|
||||
call.argument("subDir"), result
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
"queryFiles" -> {
|
||||
try {
|
||||
queryFiles(call.argument("relativePath")!!, result)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
"deleteFiles" -> {
|
||||
try {
|
||||
deleteFiles(call.argument("uris")!!, result)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFileToDownload(
|
||||
content: ByteArray, filename: String, subDir: String?,
|
||||
result: MethodChannel.Result
|
||||
) {
|
||||
try {
|
||||
val uri = MediaStoreUtil.saveFileToDownload(
|
||||
context, content, filename, subDir
|
||||
)
|
||||
result.success(uri.toString())
|
||||
} catch (e: PermissionException) {
|
||||
activity?.let { PermissionUtil.requestWriteExternalStorage(it) }
|
||||
result.error("permissionError", "Permission not granted", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToDownload(
|
||||
fromFile: String, filename: String?, subDir: String?,
|
||||
result: MethodChannel.Result
|
||||
) {
|
||||
try {
|
||||
val fromUri = inputToUri(fromFile)
|
||||
val uri = MediaStoreUtil.copyFileToDownload(
|
||||
context, fromUri, filename, subDir
|
||||
)
|
||||
result.success(uri.toString())
|
||||
} catch (e: PermissionException) {
|
||||
activity?.let { PermissionUtil.requestWriteExternalStorage(it) }
|
||||
result.error("permissionError", "Permission not granted", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryFiles(relativePath: String, result: MethodChannel.Result) {
|
||||
if (!PermissionUtil.hasReadExternalStorage(context)) {
|
||||
activity?.let { PermissionUtil.requestReadExternalStorage(it) }
|
||||
result.error("permissionError", "Permission not granted", null)
|
||||
return
|
||||
}
|
||||
|
||||
val pathColumnName: String
|
||||
val pathArg: String
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
pathColumnName = MediaStore.Images.Media.RELATIVE_PATH
|
||||
pathArg = "${relativePath}/%"
|
||||
} else {
|
||||
@Suppress("Deprecation")
|
||||
pathColumnName = MediaStore.Images.Media.DATA
|
||||
pathArg = "%/${relativePath}/%"
|
||||
}
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DATE_MODIFIED,
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MediaStore.Images.Media.DATE_TAKEN,
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
pathColumnName
|
||||
)
|
||||
val selection = StringBuilder().apply {
|
||||
append("${MediaStore.Images.Media.MIME_TYPE} LIKE ?")
|
||||
append("AND $pathColumnName LIKE ?")
|
||||
}.toString()
|
||||
val selectionArgs = arrayOf("image/%", pathArg)
|
||||
val files = context.contentResolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
projection, selection, selectionArgs, null
|
||||
)!!.use {
|
||||
val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
val dateModifiedColumn =
|
||||
it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
|
||||
val mimeTypeColumn =
|
||||
it.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
|
||||
val dateTakenColumn =
|
||||
it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
|
||||
val displayNameColumn =
|
||||
it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
val pathColumn = it.getColumnIndexOrThrow(pathColumnName)
|
||||
val products = mutableListOf<Map<String, Any>>()
|
||||
while (it.moveToNext()) {
|
||||
val id = it.getLong(idColumn)
|
||||
val dateModified = it.getLong(dateModifiedColumn)
|
||||
val mimeType = it.getString(mimeTypeColumn)
|
||||
val dateTaken = it.getLong(dateTakenColumn)
|
||||
val displayName = it.getString(displayNameColumn)
|
||||
val path = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// RELATIVE_PATH
|
||||
"${it.getString(pathColumn).trimEnd('/')}/$displayName"
|
||||
} else {
|
||||
// DATA
|
||||
it.getString(pathColumn)
|
||||
}
|
||||
val contentUri = ContentUris.withAppendedId(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
|
||||
)
|
||||
products.add(buildMap {
|
||||
put("uri", contentUri.toString())
|
||||
put("displayName", displayName)
|
||||
put("path", path)
|
||||
put("dateModified", dateModified * 1000)
|
||||
put("mimeType", mimeType)
|
||||
if (dateTaken != 0L) put("dateTaken", dateTaken)
|
||||
})
|
||||
Log.d(
|
||||
TAG,
|
||||
"[queryEnhancedPhotos] Found $displayName, path=$path, uri=$contentUri"
|
||||
)
|
||||
}
|
||||
products
|
||||
}
|
||||
Log.i(TAG, "[queryEnhancedPhotos] Found ${files.size} files")
|
||||
result.success(files)
|
||||
}
|
||||
|
||||
private fun deleteFiles(uris: List<String>, result: MethodChannel.Result) {
|
||||
val urisTyped = uris.map(Uri::parse)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val pi = MediaStore.createDeleteRequest(
|
||||
context.contentResolver, urisTyped
|
||||
)
|
||||
activity!!.startIntentSenderForResult(
|
||||
pi.intentSender, K.MEDIA_STORE_DELETE_REQUEST_CODE, null, 0, 0,
|
||||
0
|
||||
)
|
||||
result.success(null)
|
||||
} else {
|
||||
if (!PermissionUtil.hasWriteExternalStorage(context)) {
|
||||
activity?.let { PermissionUtil.requestWriteExternalStorage(it) }
|
||||
result.error("permissionError", "Permission not granted", null)
|
||||
return
|
||||
}
|
||||
|
||||
val failed = mutableListOf<String>()
|
||||
for (uri in urisTyped) {
|
||||
try {
|
||||
context.contentResolver.delete(uri, null, null)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "[deleteFiles] Failed while delete", e)
|
||||
failed += uri.toString()
|
||||
}
|
||||
}
|
||||
result.success(failed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inputToUri(fromFile: String): Uri {
|
||||
val testUri = Uri.parse(fromFile)
|
||||
return if (testUri.scheme == null) {
|
||||
// is a file path
|
||||
Uri.fromFile(File(fromFile))
|
||||
} else {
|
||||
// is a uri
|
||||
Uri.parse(fromFile)
|
||||
}
|
||||
}
|
||||
|
||||
private val context = context
|
||||
private var activity: Activity? = null
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
}
|
|
@ -1,65 +1,84 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.*
|
||||
|
||||
class MediaStoreCopyWriter(data: InputStream) {
|
||||
operator fun invoke(ostream: OutputStream) {
|
||||
data.copyTo(ostream)
|
||||
}
|
||||
|
||||
private val data = data
|
||||
}
|
||||
|
||||
interface MediaStoreUtil {
|
||||
companion object {
|
||||
/**
|
||||
* Save the @c content as a file under the user Download dir
|
||||
*
|
||||
* @param context
|
||||
* @param filename Filename of the new file
|
||||
* @param content
|
||||
* @param filename Filename of the new file
|
||||
* @param subDir
|
||||
* @return Uri of the created file
|
||||
*/
|
||||
fun saveFileToDownload(
|
||||
context: Context, filename: String, content: ByteArray
|
||||
context: Context, content: ByteArray, filename: String,
|
||||
subDir: String? = null
|
||||
): Uri {
|
||||
val stream = ByteArrayInputStream(content)
|
||||
return writeFileToDownload(context, filename, stream)
|
||||
return ByteArrayInputStream(content).use {
|
||||
writeFileToDownload(
|
||||
context, MediaStoreCopyWriter(it)::invoke, filename, subDir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file from @c fromFilePath to the user Download dir
|
||||
*
|
||||
* @param context
|
||||
* @param toFilename Filename of the new file
|
||||
* @param fromFilePath Path of the file to be copied
|
||||
* @param fromFile Path of the file to be copied
|
||||
* @param filename Filename of the new file. If null, the same filename
|
||||
* @param subDir
|
||||
* will be used
|
||||
* @return Uri of the created file
|
||||
*/
|
||||
fun copyFileToDownload(
|
||||
context: Context, toFilename: String, fromFilePath: String
|
||||
context: Context, fromFile: Uri, filename: String? = null,
|
||||
subDir: String? = null
|
||||
): Uri {
|
||||
val file = File(fromFilePath)
|
||||
val stream = file.inputStream()
|
||||
return writeFileToDownload(context, toFilename, stream)
|
||||
return context.contentResolver.openInputStream(fromFile)!!.use {
|
||||
writeFileToDownload(
|
||||
context, MediaStoreCopyWriter(it)::invoke,
|
||||
filename ?: UriUtil.resolveFilename(context, fromFile)!!,
|
||||
subDir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeFileToDownload(
|
||||
context: Context, filename: String, data: InputStream
|
||||
fun writeFileToDownload(
|
||||
context: Context, writer: (OutputStream) -> Unit, filename: String,
|
||||
subDir: String? = null
|
||||
): Uri {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
writeFileToDownload29(context, filename, data)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
writeFileToDownload30(context, writer, filename, subDir)
|
||||
} else {
|
||||
writeFileToDownload0(context, filename, data)
|
||||
writeFileToDownload0(context, writer, filename, subDir)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun writeFileToDownload29(
|
||||
context: Context, filename: String, data: InputStream
|
||||
private fun writeFileToDownload30(
|
||||
context: Context, writer: (OutputStream) -> Unit, filename: String,
|
||||
subDir: String?
|
||||
): Uri {
|
||||
// Add a media item that other apps shouldn't see until the item is
|
||||
// fully written to the media store.
|
||||
|
@ -69,37 +88,28 @@ interface MediaStoreUtil {
|
|||
val collection = MediaStore.Downloads.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
val file = File(filename)
|
||||
val details = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, file.name)
|
||||
if (file.parent != null) {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, filename)
|
||||
if (subDir != null) {
|
||||
put(
|
||||
MediaStore.Downloads.RELATIVE_PATH,
|
||||
"${Environment.DIRECTORY_DOWNLOADS}/${file.parent}"
|
||||
"${Environment.DIRECTORY_DOWNLOADS}/$subDir"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val contentUri = resolver.insert(collection, details)
|
||||
|
||||
resolver.openFileDescriptor(contentUri!!, "w", null).use { pfd ->
|
||||
// Write data into the pending audio file.
|
||||
BufferedOutputStream(
|
||||
FileOutputStream(pfd!!.fileDescriptor)
|
||||
).use { stream ->
|
||||
data.copyTo(stream)
|
||||
}
|
||||
resolver.openOutputStream(contentUri!!).use {
|
||||
writer(it!!)
|
||||
}
|
||||
return contentUri
|
||||
}
|
||||
|
||||
private fun writeFileToDownload0(
|
||||
context: Context, filename: String, data: InputStream
|
||||
context: Context, writer: (OutputStream) -> Unit, filename: String,
|
||||
subDir: String?
|
||||
): Uri {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (!PermissionUtil.hasWriteExternalStorage(context)) {
|
||||
throw PermissionException("Permission not granted")
|
||||
}
|
||||
|
||||
|
@ -107,19 +117,19 @@ interface MediaStoreUtil {
|
|||
val path = Environment.getExternalStoragePublicDirectory(
|
||||
Environment.DIRECTORY_DOWNLOADS
|
||||
)
|
||||
var file = File(path, filename)
|
||||
val prefix = if (subDir != null) "$subDir/" else ""
|
||||
var file = File(path, prefix + filename)
|
||||
val baseFilename = file.nameWithoutExtension
|
||||
var count = 1
|
||||
while (file.exists()) {
|
||||
val f = File(filename)
|
||||
file = File(
|
||||
path,
|
||||
"${f.nameWithoutExtension} ($count).${f.extension}"
|
||||
path, prefix + "$baseFilename ($count).${file.extension}"
|
||||
)
|
||||
++count
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
BufferedOutputStream(FileOutputStream(file)).use { stream ->
|
||||
data.copyTo(stream)
|
||||
writer(stream)
|
||||
}
|
||||
|
||||
val fileUri = Uri.fromFile(file)
|
||||
|
@ -138,6 +148,5 @@ interface MediaStoreUtil {
|
|||
}
|
||||
context.sendBroadcast(scanIntent)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,26 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.annotation.NonNull
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class NcPhotosPlugin : FlutterPlugin, ActivityAware,
|
||||
PluginRegistry.ActivityResultListener,
|
||||
PluginRegistry.RequestPermissionsResultListener {
|
||||
companion object {
|
||||
const val ACTION_SHOW_IMAGE_PROCESSOR_RESULT =
|
||||
K.ACTION_SHOW_IMAGE_PROCESSOR_RESULT
|
||||
const val EXTRA_IMAGE_RESULT_URI = K.EXTRA_IMAGE_RESULT_URI
|
||||
|
||||
private const val TAG = "NcPhotosPlugin"
|
||||
}
|
||||
|
||||
class NcPhotosPlugin : FlutterPlugin {
|
||||
override fun onAttachedToEngine(
|
||||
@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding
|
||||
) {
|
||||
|
@ -36,6 +51,50 @@ class NcPhotosPlugin : FlutterPlugin {
|
|||
NativeEventChannelHandler.METHOD_CHANNEL
|
||||
)
|
||||
nativeEventMethodChannel.setMethodCallHandler(nativeEventHandler)
|
||||
|
||||
mediaStoreChannelHandler =
|
||||
MediaStoreChannelHandler(flutterPluginBinding.applicationContext)
|
||||
mediaStoreChannel = EventChannel(
|
||||
flutterPluginBinding.binaryMessenger,
|
||||
MediaStoreChannelHandler.EVENT_CHANNEL
|
||||
)
|
||||
mediaStoreChannel.setStreamHandler(mediaStoreChannelHandler)
|
||||
mediaStoreMethodChannel = MethodChannel(
|
||||
flutterPluginBinding.binaryMessenger,
|
||||
MediaStoreChannelHandler.METHOD_CHANNEL
|
||||
)
|
||||
mediaStoreMethodChannel.setMethodCallHandler(mediaStoreChannelHandler)
|
||||
|
||||
imageProcessorMethodChannel = MethodChannel(
|
||||
flutterPluginBinding.binaryMessenger,
|
||||
ImageProcessorChannelHandler.METHOD_CHANNEL
|
||||
)
|
||||
imageProcessorMethodChannel.setMethodCallHandler(
|
||||
ImageProcessorChannelHandler(
|
||||
flutterPluginBinding.applicationContext
|
||||
)
|
||||
)
|
||||
|
||||
contentUriMethodChannel = MethodChannel(
|
||||
flutterPluginBinding.binaryMessenger,
|
||||
ContentUriChannelHandler.METHOD_CHANNEL
|
||||
)
|
||||
contentUriMethodChannel.setMethodCallHandler(
|
||||
ContentUriChannelHandler(flutterPluginBinding.applicationContext)
|
||||
)
|
||||
|
||||
permissionChannelHandler =
|
||||
PermissionChannelHandler(flutterPluginBinding.applicationContext)
|
||||
permissionChannel = EventChannel(
|
||||
flutterPluginBinding.binaryMessenger,
|
||||
PermissionChannelHandler.EVENT_CHANNEL
|
||||
)
|
||||
permissionChannel.setStreamHandler(permissionChannelHandler)
|
||||
permissionMethodChannel = MethodChannel(
|
||||
flutterPluginBinding.binaryMessenger,
|
||||
PermissionChannelHandler.METHOD_CHANNEL
|
||||
)
|
||||
permissionMethodChannel.setMethodCallHandler(permissionChannelHandler)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(
|
||||
|
@ -46,12 +105,100 @@ class NcPhotosPlugin : FlutterPlugin {
|
|||
notificationChannel.setMethodCallHandler(null)
|
||||
nativeEventChannel.setStreamHandler(null)
|
||||
nativeEventMethodChannel.setMethodCallHandler(null)
|
||||
mediaStoreMethodChannel.setMethodCallHandler(null)
|
||||
imageProcessorMethodChannel.setMethodCallHandler(null)
|
||||
contentUriMethodChannel.setMethodCallHandler(null)
|
||||
permissionMethodChannel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
mediaStoreChannelHandler.onAttachedToActivity(binding)
|
||||
permissionChannelHandler.onAttachedToActivity(binding)
|
||||
pluginBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
binding.addRequestPermissionsResultListener(this)
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(
|
||||
binding: ActivityPluginBinding
|
||||
) {
|
||||
mediaStoreChannelHandler.onReattachedToActivityForConfigChanges(binding)
|
||||
permissionChannelHandler.onReattachedToActivityForConfigChanges(binding)
|
||||
pluginBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
binding.addRequestPermissionsResultListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
mediaStoreChannelHandler.onDetachedFromActivity()
|
||||
permissionChannelHandler.onDetachedFromActivity()
|
||||
pluginBinding?.removeActivityResultListener(this)
|
||||
pluginBinding?.removeRequestPermissionsResultListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
mediaStoreChannelHandler.onDetachedFromActivityForConfigChanges()
|
||||
permissionChannelHandler.onDetachedFromActivityForConfigChanges()
|
||||
pluginBinding?.removeActivityResultListener(this)
|
||||
pluginBinding?.removeRequestPermissionsResultListener(this)
|
||||
}
|
||||
|
||||
override fun onActivityResult(
|
||||
requestCode: Int, resultCode: Int, data: Intent?
|
||||
): Boolean {
|
||||
return try {
|
||||
when (requestCode) {
|
||||
K.MEDIA_STORE_DELETE_REQUEST_CODE -> {
|
||||
mediaStoreChannelHandler.onActivityResult(
|
||||
requestCode, resultCode, data
|
||||
)
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(
|
||||
TAG, "Failed while onActivityResult, requestCode=$requestCode"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
): Boolean {
|
||||
return try {
|
||||
when (requestCode) {
|
||||
K.PERMISSION_REQUEST_CODE -> {
|
||||
permissionChannelHandler.onRequestPermissionsResult(
|
||||
requestCode, permissions, grantResults
|
||||
)
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(
|
||||
TAG, "Failed while onActivityResult, requestCode=$requestCode"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private var pluginBinding: ActivityPluginBinding? = null
|
||||
|
||||
private lateinit var lockChannel: MethodChannel
|
||||
private lateinit var notificationChannel: MethodChannel
|
||||
private lateinit var nativeEventChannel: EventChannel
|
||||
private lateinit var nativeEventMethodChannel: MethodChannel
|
||||
private lateinit var mediaStoreChannel: EventChannel
|
||||
private lateinit var mediaStoreMethodChannel: MethodChannel
|
||||
private lateinit var imageProcessorMethodChannel: MethodChannel
|
||||
private lateinit var contentUriMethodChannel: MethodChannel
|
||||
private lateinit var permissionChannel: EventChannel
|
||||
private lateinit var permissionMethodChannel: MethodChannel
|
||||
|
||||
private lateinit var lockChannelHandler: LockChannelHandler
|
||||
private lateinit var mediaStoreChannelHandler: MediaStoreChannelHandler
|
||||
private lateinit var permissionChannelHandler: PermissionChannelHandler
|
||||
}
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class PermissionChannelHandler(context: Context) :
|
||||
MethodChannel.MethodCallHandler, EventChannel.StreamHandler, ActivityAware,
|
||||
PluginRegistry.RequestPermissionsResultListener {
|
||||
companion object {
|
||||
const val EVENT_CHANNEL = "${K.LIB_ID}/permission"
|
||||
const val METHOD_CHANNEL = "${K.LIB_ID}/permission_method"
|
||||
|
||||
private const val TAG = "PermissionChannelHandler"
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(
|
||||
binding: ActivityPluginBinding
|
||||
) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
): Boolean {
|
||||
return if (requestCode == K.PERMISSION_REQUEST_CODE) {
|
||||
eventSink?.success(buildMap {
|
||||
put("event", "RequestPermissionsResult")
|
||||
put(
|
||||
"grantResults",
|
||||
permissions.zip(grantResults.toTypedArray()).toMap()
|
||||
)
|
||||
})
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
eventSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"request" -> {
|
||||
try {
|
||||
request(call.argument("permissions")!!, result)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.toString(), null)
|
||||
}
|
||||
}
|
||||
|
||||
"hasWriteExternalStorage" -> {
|
||||
try {
|
||||
result.success(
|
||||
PermissionUtil.hasWriteExternalStorage(context)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.toString(), null)
|
||||
}
|
||||
}
|
||||
|
||||
"hasReadExternalStorage" -> {
|
||||
try {
|
||||
result.success(
|
||||
PermissionUtil.hasReadExternalStorage(context)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("systemException", e.toString(), null)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun request(
|
||||
permissions: List<String>, result: MethodChannel.Result
|
||||
) {
|
||||
if (activity == null) {
|
||||
result.error("systemException", "Activity is not ready", null)
|
||||
return
|
||||
}
|
||||
PermissionUtil.request(activity!!, *permissions.toTypedArray())
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private val context = context
|
||||
private var activity: Activity? = null
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
interface PermissionUtil {
|
||||
companion object {
|
||||
fun request(activity: Activity, vararg permissions: String) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity, permissions, K.PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
|
||||
fun hasReadExternalStorage(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun requestReadExternalStorage(activity: Activity) =
|
||||
request(activity, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
|
||||
fun hasWriteExternalStorage(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun requestWriteExternalStorage(activity: Activity) =
|
||||
request(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
|
||||
interface UriUtil {
|
||||
companion object {
|
||||
fun resolveFilename(context: Context, uri: Uri): String? {
|
||||
return if (uri.scheme == "file") {
|
||||
uri.lastPathSegment!!
|
||||
} else {
|
||||
context.contentResolver.query(
|
||||
uri, arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null,
|
||||
null, null
|
||||
).use {
|
||||
if (it == null || !it.moveToFirst()) {
|
||||
Log.i(TAG, "Uri not found: $uri")
|
||||
null
|
||||
} else {
|
||||
it.getString(
|
||||
it.getColumnIndexOrThrow(
|
||||
MediaStore.MediaColumns.DISPLAY_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "UriUtil"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package com.nkming.nc_photos.plugin
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.os.Build
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
fun getPendingIntentFlagImmutable(): Int {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
PendingIntent.FLAG_IMMUTABLE else 0
|
||||
}
|
||||
|
||||
fun getPendingIntentFlagMutable(): Int {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
PendingIntent.FLAG_MUTABLE else 0
|
||||
}
|
||||
|
||||
inline fun <T> HttpURLConnection.use(block: (HttpURLConnection) -> T): T {
|
||||
try {
|
||||
connect()
|
||||
return block(this)
|
||||
} finally {
|
||||
disconnect()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package com.nkming.nc_photos.plugin.image_processor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.FloatBuffer
|
||||
import java.nio.IntBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlin.math.abs
|
||||
|
||||
interface TfLiteHelper {
|
||||
companion object {
|
||||
/**
|
||||
* Load a TFLite model from the assets dir
|
||||
*
|
||||
* @param context
|
||||
* @param name Name of the model file
|
||||
* @return
|
||||
*/
|
||||
fun loadModelFromAsset(context: Context, name: String): ByteBuffer {
|
||||
val fd = context.assets.openFd(name)
|
||||
val istream = FileInputStream(fd.fileDescriptor)
|
||||
val channel = istream.channel
|
||||
return channel.map(
|
||||
FileChannel.MapMode.READ_ONLY, fd.startOffset, fd.declaredLength
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ARGB_8888 Android bitmap to a float RGB buffer
|
||||
*
|
||||
* @param bitmap
|
||||
* @return
|
||||
*/
|
||||
fun bitmapToRgbFloatArray(bitmap: Bitmap): FloatBuffer {
|
||||
val buffer = IntBuffer.allocate(bitmap.width * bitmap.height)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
val input = FloatBuffer.allocate(bitmap.width * bitmap.height * 3)
|
||||
buffer.array().forEach {
|
||||
input.put((it and 0xFF) / 255.0f)
|
||||
input.put((it shr 8 and 0xFF) / 255.0f)
|
||||
input.put((it shr 16 and 0xFF) / 255.0f)
|
||||
}
|
||||
input.rewind()
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a float RGB buffer to an ARGB_8888 Android bitmap
|
||||
*
|
||||
* @param output
|
||||
* @param width
|
||||
* @param height
|
||||
* @return
|
||||
*/
|
||||
fun rgbFloatArrayToBitmap(
|
||||
output: FloatBuffer, width: Int, height: Int
|
||||
): Bitmap {
|
||||
val buffer = IntBuffer.allocate(width * height)
|
||||
var i = 0
|
||||
var pixel = 0
|
||||
output.array().forEach {
|
||||
val value = (abs(it * 255f)).toInt().coerceIn(0, 255)
|
||||
when (i++) {
|
||||
0 -> {
|
||||
// A
|
||||
pixel = 0xFF shl 24
|
||||
// R
|
||||
pixel = pixel or value
|
||||
}
|
||||
1 -> {
|
||||
// G
|
||||
pixel = pixel or (value shl 8)
|
||||
}
|
||||
2 -> {
|
||||
// B
|
||||
pixel = pixel or (value shl 16)
|
||||
|
||||
buffer.put(pixel)
|
||||
i = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer.rewind()
|
||||
val outputBitmap =
|
||||
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
outputBitmap.copyPixelsFromBuffer(buffer)
|
||||
return outputBitmap
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package com.nkming.nc_photos.plugin.image_processor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.nkming.nc_photos.plugin.BitmapResizeMethod
|
||||
import com.nkming.nc_photos.plugin.BitmapUtil
|
||||
import org.tensorflow.lite.Interpreter
|
||||
import java.nio.FloatBuffer
|
||||
import kotlin.math.pow
|
||||
|
||||
class ZeroDce(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "ZeroDce"
|
||||
private const val MODEL = "zero_dce_lite_200x300_iter8_60.tflite"
|
||||
private const val WIDTH = 300
|
||||
private const val HEIGHT = 200
|
||||
private const val ITERATION = 8
|
||||
|
||||
private const val MAX_WIDTH = 2048
|
||||
private const val MAX_HEIGHT = 1536
|
||||
}
|
||||
|
||||
fun infer(imageUri: Uri): Bitmap {
|
||||
val alphaMaps = inferAlphaMaps(imageUri)
|
||||
return enhance(imageUri, alphaMaps, ITERATION)
|
||||
}
|
||||
|
||||
private fun inferAlphaMaps(imageUri: Uri): Bitmap {
|
||||
val interpreter =
|
||||
Interpreter(TfLiteHelper.loadModelFromAsset(context, MODEL))
|
||||
interpreter.allocateTensors()
|
||||
|
||||
Log.i(TAG, "Converting bitmap to input")
|
||||
val inputBitmap =
|
||||
BitmapUtil.loadImageFixed(context, imageUri, WIDTH, HEIGHT)
|
||||
val inputs = arrayOf(TfLiteHelper.bitmapToRgbFloatArray(inputBitmap))
|
||||
val outputs = mapOf(
|
||||
0 to FloatBuffer.allocate(inputs[0].capacity()),
|
||||
1 to FloatBuffer.allocate(inputs[0].capacity())
|
||||
)
|
||||
Log.i(TAG, "Inferring")
|
||||
interpreter.runForMultipleInputsOutputs(inputs, outputs)
|
||||
|
||||
return TfLiteHelper.rgbFloatArrayToBitmap(
|
||||
outputs[1]!!, inputBitmap.width, inputBitmap.height
|
||||
)
|
||||
}
|
||||
|
||||
private fun enhance(
|
||||
imageUri: Uri, alphaMaps: Bitmap, iteration: Int
|
||||
): Bitmap {
|
||||
Log.i(TAG, "Enhancing image, iteration: $iteration")
|
||||
// downscale original to prevent OOM
|
||||
val resized = BitmapUtil.loadImage(
|
||||
context, imageUri, MAX_WIDTH, MAX_HEIGHT, BitmapResizeMethod.FIT,
|
||||
isAllowSwapSide = true, shouldUpscale = false
|
||||
)
|
||||
// resize aMaps
|
||||
val resizedFilter = Bitmap.createScaledBitmap(
|
||||
alphaMaps, resized.width, resized.height, true
|
||||
)
|
||||
|
||||
val imgBuf = TfLiteHelper.bitmapToRgbFloatArray(resized)
|
||||
val filterBuf = TfLiteHelper.bitmapToRgbFloatArray(resizedFilter)
|
||||
for (i in 0 until iteration) {
|
||||
val src = imgBuf.array()
|
||||
val filter = filterBuf.array()
|
||||
for (j in src.indices) {
|
||||
src[j] = src[j] + -filter[j] * (src[j].pow(2f) - src[j])
|
||||
}
|
||||
}
|
||||
return TfLiteHelper.rgbFloatArrayToBitmap(
|
||||
imgBuf, resized.width, resized.height
|
||||
)
|
||||
}
|
||||
|
||||
private val context = context
|
||||
}
|
After Width: | Height: | Size: 400 B |
After Width: | Height: | Size: 253 B |
After Width: | Height: | Size: 254 B |
After Width: | Height: | Size: 177 B |
After Width: | Height: | Size: 410 B |
After Width: | Height: | Size: 295 B |
After Width: | Height: | Size: 654 B |
After Width: | Height: | Size: 407 B |
After Width: | Height: | Size: 749 B |
After Width: | Height: | Size: 532 B |
|
@ -1,5 +1,10 @@
|
|||
library nc_photos_plugin;
|
||||
|
||||
export 'src/content_uri.dart';
|
||||
export 'src/exception.dart';
|
||||
export 'src/image_processor.dart';
|
||||
export 'src/lock.dart';
|
||||
export 'src/media_store.dart';
|
||||
export 'src/native_event.dart';
|
||||
export 'src/notification.dart';
|
||||
export 'src/permission.dart';
|
||||
|
|
26
plugin/lib/src/content_uri.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:nc_photos_plugin/src/exception.dart';
|
||||
import 'package:nc_photos_plugin/src/k.dart' as k;
|
||||
|
||||
class ContentUri {
|
||||
static Future<Uint8List> readUri(String uri) async {
|
||||
try {
|
||||
return await _methodChannel.invokeMethod("readUri", <String, dynamic>{
|
||||
"uri": uri,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _exceptionFileNotFound) {
|
||||
throw const FileNotFoundException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static const _methodChannel = MethodChannel("${k.libId}/content_uri_method");
|
||||
|
||||
static const _exceptionFileNotFound = "fileNotFoundException";
|
||||
}
|
30
plugin/lib/src/exception.dart
Normal file
|
@ -0,0 +1,30 @@
|
|||
class FileNotFoundException implements Exception {
|
||||
const FileNotFoundException([this.message]);
|
||||
|
||||
@override
|
||||
toString() {
|
||||
if (message == null) {
|
||||
return "FileNotFoundException";
|
||||
} else {
|
||||
return "FileNotFoundException: $message";
|
||||
}
|
||||
}
|
||||
|
||||
final dynamic message;
|
||||
}
|
||||
|
||||
/// Platform permission is not granted by user
|
||||
class PermissionException implements Exception {
|
||||
const PermissionException([this.message]);
|
||||
|
||||
@override
|
||||
toString() {
|
||||
if (message == null) {
|
||||
return "PermissionException";
|
||||
} else {
|
||||
return "PermissionException: $message";
|
||||
}
|
||||
}
|
||||
|
||||
final dynamic message;
|
||||
}
|
20
plugin/lib/src/image_processor.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:nc_photos_plugin/src/k.dart' as k;
|
||||
|
||||
class ImageProcessor {
|
||||
static Future<void> zeroDce(
|
||||
String fileUrl,
|
||||
String filename, {
|
||||
Map<String, String>? headers,
|
||||
}) =>
|
||||
_methodChannel.invokeMethod("zeroDce", <String, dynamic>{
|
||||
"fileUrl": fileUrl,
|
||||
"headers": headers,
|
||||
"filename": filename,
|
||||
});
|
||||
|
||||
static const _methodChannel =
|
||||
MethodChannel("${k.libId}/image_processor_method");
|
||||
}
|
127
plugin/lib/src/media_store.dart
Normal file
|
@ -0,0 +1,127 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos_plugin/src/exception.dart';
|
||||
import 'package:nc_photos_plugin/src/k.dart' as k;
|
||||
|
||||
class MediaStoreQueryResult {
|
||||
const MediaStoreQueryResult(this.uri, this.displayName, this.path,
|
||||
this.dateModified, this.mimeType, this.dateTaken);
|
||||
|
||||
final String uri;
|
||||
final String displayName;
|
||||
final String path;
|
||||
final int dateModified;
|
||||
final String? mimeType;
|
||||
final int? dateTaken;
|
||||
}
|
||||
|
||||
class MediaStoreDeleteRequestResultEvent {
|
||||
const MediaStoreDeleteRequestResultEvent(this.resultCode);
|
||||
|
||||
final int resultCode;
|
||||
}
|
||||
|
||||
class MediaStore {
|
||||
static Future<String> saveFileToDownload(
|
||||
Uint8List content,
|
||||
String filename, {
|
||||
String? subDir,
|
||||
}) async {
|
||||
try {
|
||||
return (await _methodChannel
|
||||
.invokeMethod<String>("saveFileToDownload", <String, dynamic>{
|
||||
"content": content,
|
||||
"filename": filename,
|
||||
"subDir": subDir,
|
||||
}))!;
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _exceptionCodePermissionError) {
|
||||
throw const PermissionException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a file to the user Download dir
|
||||
///
|
||||
/// [fromFile] must be either a path or a content uri. If [filename] is not
|
||||
/// null, it will be used instead of the source filename
|
||||
static Future<String> copyFileToDownload(
|
||||
String fromFile, {
|
||||
String? filename,
|
||||
String? subDir,
|
||||
}) async {
|
||||
try {
|
||||
return (await _methodChannel
|
||||
.invokeMethod<String>("copyFileToDownload", <String, dynamic>{
|
||||
"fromFile": fromFile,
|
||||
"filename": filename,
|
||||
"subDir": subDir,
|
||||
}))!;
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _exceptionCodePermissionError) {
|
||||
throw const PermissionException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return files under [relativePath] and its sub dirs
|
||||
static Future<List<MediaStoreQueryResult>> queryFiles(
|
||||
String relativePath) async {
|
||||
try {
|
||||
final List results =
|
||||
await _methodChannel.invokeMethod("queryFiles", <String, dynamic>{
|
||||
"relativePath": relativePath,
|
||||
});
|
||||
return results
|
||||
.cast<Map>()
|
||||
.map((e) => MediaStoreQueryResult(e["uri"], e["displayName"],
|
||||
e["path"], e["dateModified"], e["mimeType"], e["dateTaken"]))
|
||||
.toList();
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _exceptionCodePermissionError) {
|
||||
throw const PermissionException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<String>?> deleteFiles(List<String> uris) async {
|
||||
return (await _methodChannel
|
||||
.invokeMethod<List>("deleteFiles", <String, dynamic>{
|
||||
"uris": uris,
|
||||
}))
|
||||
?.cast<String>();
|
||||
}
|
||||
|
||||
static Stream get stream => _eventStream;
|
||||
|
||||
static late final _eventStream =
|
||||
_eventChannel.receiveBroadcastStream().map((event) {
|
||||
if (event is Map) {
|
||||
switch (event["event"]) {
|
||||
case _eventDeleteRequestResult:
|
||||
return MediaStoreDeleteRequestResultEvent(event["resultCode"]);
|
||||
|
||||
default:
|
||||
_log.shout("[_eventStream] Unknown event: ${event["event"]}");
|
||||
}
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
});
|
||||
|
||||
static const _eventChannel = EventChannel("${k.libId}/media_store");
|
||||
static const _methodChannel = MethodChannel("${k.libId}/media_store_method");
|
||||
|
||||
static const _exceptionCodePermissionError = "permissionError";
|
||||
static const _eventDeleteRequestResult = "DeleteRequestResult";
|
||||
|
||||
static final _log = Logger("media_store.MediaStore");
|
||||
}
|
62
plugin/lib/src/permission.dart
Normal file
|
@ -0,0 +1,62 @@
|
|||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos_plugin/src/k.dart' as k;
|
||||
|
||||
class Permission {
|
||||
static const READ_EXTERNAL_STORAGE =
|
||||
"android.permission.READ_EXTERNAL_STORAGE";
|
||||
static const WRITE_EXTERNAL_STORAGE =
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE";
|
||||
|
||||
static Future<void> request(List<String> permissions) =>
|
||||
_methodChannel.invokeMethod("request", <String, dynamic>{
|
||||
"permissions": permissions,
|
||||
});
|
||||
|
||||
static Future<bool> hasWriteExternalStorage() async {
|
||||
return (await _methodChannel
|
||||
.invokeMethod<bool>("hasWriteExternalStorage"))!;
|
||||
}
|
||||
|
||||
static Future<bool> hasReadExternalStorage() async {
|
||||
return (await _methodChannel.invokeMethod<bool>("hasReadExternalStorage"))!;
|
||||
}
|
||||
|
||||
static Stream get stream => _eventStream;
|
||||
|
||||
static late final _eventStream =
|
||||
_eventChannel.receiveBroadcastStream().map((event) {
|
||||
if (event is Map) {
|
||||
switch (event["event"]) {
|
||||
case _eventRequestPermissionsResult:
|
||||
return PermissionRequestResult(
|
||||
(event["grantResults"] as Map).cast<String, int>());
|
||||
|
||||
default:
|
||||
_log.shout("[_eventStream] Unknown event: ${event["event"]}");
|
||||
}
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
});
|
||||
|
||||
static const _eventChannel = EventChannel("${k.libId}/permission");
|
||||
static const _methodChannel = MethodChannel("${k.libId}/permission_method");
|
||||
|
||||
static const _eventRequestPermissionsResult = "RequestPermissionsResult";
|
||||
|
||||
static final _log = Logger("plugin.permission.Permission");
|
||||
}
|
||||
|
||||
class PermissionRequestResult {
|
||||
static const granted = 0;
|
||||
static const denied = -1;
|
||||
|
||||
const PermissionRequestResult(this.grantResults);
|
||||
|
||||
final Map<String, int> grantResults;
|
||||
}
|
|
@ -34,6 +34,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -11,6 +11,8 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
logging: ^1.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^1.0.0
|
||||
|
||||
|
|