Merge branch 'photo-enhancement' into dev

This commit is contained in:
Ming Ming 2022-05-11 03:01:01 +08:00
commit 0c400786e0
78 changed files with 3786 additions and 263 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
));

View 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");
}

View file

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

View file

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

View file

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

View 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,
});
}

View 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");
}

View file

@ -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);

View file

@ -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]);

View 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);
}

View file

@ -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";

View file

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

View file

@ -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"
]
}

View 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");
}

View 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;
}

View 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;

View file

@ -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");
}

View 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");

View file

@ -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();
}

View file

@ -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);
}

View file

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

View file

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

View 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");
}

View file

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

View 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;
}

View 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");
}

View 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,
}

View 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");
}

View 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;
}

View file

@ -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),

View file

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

View 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,
}

View file

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

View file

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

View file

@ -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}");

View file

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

View file

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

View file

@ -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'
}

View file

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

View file

@ -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"
}
}

View file

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

View file

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

View file

@ -1,3 +1,5 @@
package com.nkming.nc_photos.plugin
class PermissionException(message: String) : Exception(message)
class HttpException(statusCode: Int, message: String): Exception(message)

View file

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

View file

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

View file

@ -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"
}
}

View file

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

View file

@ -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)
}
}
}

View file

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

View file

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

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

View file

@ -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';

View 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";
}

View 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;
}

View 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");
}

View 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");
}

View 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;
}

View file

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

View file

@ -11,6 +11,8 @@ dependencies:
flutter:
sdk: flutter
logging: ^1.0.2
dev_dependencies:
flutter_lints: ^1.0.0