mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 20:42:12 +00:00
remove permissions again
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
This commit is contained in:
parent
849f748968
commit
d03c42659c
15 changed files with 396 additions and 105 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.2.25
|
## 0.2.26
|
||||||
|
|
||||||
- New: Import images from the gallery
|
- New: Import images from the gallery
|
||||||
- Improved: Media files are now stored in the dedicated "twonly" album
|
- Improved: Media files are now stored in the dedicated "twonly" album
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,6 @@
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="com.android.vending.BILLING" />
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,32 @@ import android.content.Context
|
||||||
import io.crates.keyring.Keyring
|
import io.crates.keyring.Keyring
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
|
private val CHANNEL = "eu.twonly/photo_picker"
|
||||||
|
private var pendingResult: MethodChannel.Result? = null
|
||||||
|
|
||||||
|
private lateinit var pickMultipleMedia: ActivityResultLauncher<PickVisualMediaRequest>
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
|
|
||||||
|
pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
val uriStrings = uris.map { it.toString() }
|
||||||
|
pendingResult?.success(uriStrings)
|
||||||
|
} else {
|
||||||
|
pendingResult?.success(emptyList<String>())
|
||||||
|
}
|
||||||
|
pendingResult = null
|
||||||
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,5 +57,35 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
Keyring.initializeNdkContext(applicationContext)
|
Keyring.initializeNdkContext(applicationContext)
|
||||||
|
|
||||||
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"pickImages" -> {
|
||||||
|
pendingResult = result
|
||||||
|
pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||||
|
}
|
||||||
|
"getUriBytes" -> {
|
||||||
|
val uriString = call.argument<String>("uri")
|
||||||
|
if (uriString != null) {
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
val inputStream: InputStream? = contentResolver.openInputStream(uri)
|
||||||
|
if (inputStream != null) {
|
||||||
|
val bytes = inputStream.readBytes()
|
||||||
|
inputStream.close()
|
||||||
|
result.success(bytes)
|
||||||
|
} else {
|
||||||
|
result.error("UNAVAILABLE", "Could not open InputStream", null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_ARGUMENT", "URI string is null", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1484,6 +1484,12 @@ abstract class AppLocalizations {
|
||||||
/// **'The image will be irrevocably deleted.'**
|
/// **'The image will be irrevocably deleted.'**
|
||||||
String get deleteImageBody;
|
String get deleteImageBody;
|
||||||
|
|
||||||
|
/// No description provided for @deleteMemoriesBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
||||||
|
String deleteMemoriesBody(num count);
|
||||||
|
|
||||||
/// No description provided for @settingsBackup.
|
/// No description provided for @settingsBackup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -772,6 +772,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get deleteImageBody => 'Das Bild wird unwiderruflich gelöscht.';
|
String get deleteImageBody => 'Das Bild wird unwiderruflich gelöscht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deleteMemoriesBody(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Die $count Bilder werden unwiderruflich gelöscht.',
|
||||||
|
one: 'Das Bild wird unwiderruflich gelöscht.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -766,6 +766,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get deleteImageBody => 'The image will be irrevocably deleted.';
|
String get deleteImageBody => 'The image will be irrevocably deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deleteMemoriesBody(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'The $count images will be irrevocably deleted.',
|
||||||
|
one: 'The image will be irrevocably deleted.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 1da4b1c1bf172914fffad86d39a6ce4ca2845c01
|
Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235
|
||||||
25
lib/src/services/android_photo_picker.service.dart
Normal file
25
lib/src/services/android_photo_picker.service.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class AndroidPhotoPickerService {
|
||||||
|
static const MethodChannel _channel = MethodChannel('eu.twonly/photo_picker');
|
||||||
|
|
||||||
|
/// Launches the native Android Photo Picker and returns a list of URIs.
|
||||||
|
static Future<List<String>> pickImages() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeListMethod<String>('pickImages');
|
||||||
|
return result ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the raw bytes from a content URI using the Android ContentResolver.
|
||||||
|
static Future<Uint8List?> getUriBytes(String uri) async {
|
||||||
|
try {
|
||||||
|
final bytes = await _channel.invokeMethod<Uint8List>('getUriBytes', {'uri': uri});
|
||||||
|
return bytes;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
@ -310,6 +311,7 @@ class MediaFileService {
|
||||||
} else {
|
} else {
|
||||||
await saveImageToGallery(
|
await saveImageToGallery(
|
||||||
storedPath.readAsBytesSync(),
|
storedPath.readAsBytesSync(),
|
||||||
|
createdAt: mediaFile.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -447,106 +449,41 @@ class MediaFileService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final bytes = storedPath.readAsBytesSync();
|
final bytes = storedPath.readAsBytesSync();
|
||||||
final image = img.decodeImage(bytes);
|
final result = await compute(_processImageCrop, bytes);
|
||||||
if (image == null) {
|
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
|
||||||
mediaFile.mediaId,
|
|
||||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var minY = 0;
|
if (result.isCropped && result.pngBytes != null) {
|
||||||
var maxY = image.height - 1;
|
try {
|
||||||
var minX = 0;
|
|
||||||
var maxX = image.width - 1;
|
|
||||||
|
|
||||||
var found = false;
|
|
||||||
for (var y = 0; y < image.height; y++) {
|
|
||||||
for (var x = 0; x < image.width; x++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
minY = y;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false;
|
|
||||||
for (var y = image.height - 1; y >= minY; y--) {
|
|
||||||
for (var x = 0; x < image.width; x++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
maxY = y;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false;
|
|
||||||
for (var x = 0; x < image.width; x++) {
|
|
||||||
for (var y = minY; y <= maxY; y++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
minX = x;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false;
|
|
||||||
for (var x = image.width - 1; x >= minX; x--) {
|
|
||||||
for (var y = minY; y <= maxY; y++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
maxX = x;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final newWidth = maxX - minX + 1;
|
|
||||||
final newHeight = maxY - minY + 1;
|
|
||||||
|
|
||||||
if (minY > 0 ||
|
|
||||||
maxY < image.height - 1 ||
|
|
||||||
minX > 0 ||
|
|
||||||
maxX < image.width - 1) {
|
|
||||||
if (newWidth > 10 && newHeight > 10) {
|
|
||||||
final cropped = img.copyCrop(
|
|
||||||
image,
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: newWidth,
|
|
||||||
height: newHeight,
|
|
||||||
);
|
|
||||||
final pngBytes = img.encodePng(cropped);
|
|
||||||
final webpBytes = await FlutterImageCompress.compressWithList(
|
final webpBytes = await FlutterImageCompress.compressWithList(
|
||||||
pngBytes,
|
result.pngBytes!,
|
||||||
format: CompressFormat.webp,
|
format: CompressFormat.webp,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
);
|
);
|
||||||
storedPath.writeAsBytesSync(webpBytes);
|
|
||||||
|
|
||||||
if (thumbnailPath.existsSync()) {
|
if (webpBytes.isNotEmpty) {
|
||||||
thumbnailPath.deleteSync();
|
storedPath.writeAsBytesSync(webpBytes);
|
||||||
|
} else {
|
||||||
|
Log.warn('WebP compression returned empty, falling back to PNG');
|
||||||
|
storedPath.writeAsBytesSync(result.pngBytes!);
|
||||||
}
|
}
|
||||||
await createThumbnail();
|
} catch (e) {
|
||||||
final checksum = await sha256File(storedPath);
|
Log.error('Error compressing to WebP, falling back to PNG: $e');
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
storedPath.writeAsBytesSync(result.pngBytes!);
|
||||||
mediaFile.mediaId,
|
|
||||||
MediaFilesCompanion(
|
|
||||||
hasCropAnalyzed: const Value(true),
|
|
||||||
storedFileHash: Value(Uint8List.fromList(checksum)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await updateFromDB();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (thumbnailPath.existsSync()) {
|
||||||
|
thumbnailPath.deleteSync();
|
||||||
|
}
|
||||||
|
await createThumbnail();
|
||||||
|
final checksum = await sha256File(storedPath);
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
MediaFilesCompanion(
|
||||||
|
hasCropAnalyzed: const Value(true),
|
||||||
|
storedFileHash: Value(Uint8List.fromList(checksum)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await updateFromDB();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
|
@ -566,3 +503,89 @@ class MediaFileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CropResult {
|
||||||
|
const _CropResult(this.pngBytes, this.isCropped);
|
||||||
|
final Uint8List? pngBytes;
|
||||||
|
final bool isCropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
_CropResult _processImageCrop(Uint8List bytes) {
|
||||||
|
final image = img.decodeImage(bytes);
|
||||||
|
if (image == null) return const _CropResult(null, false);
|
||||||
|
|
||||||
|
var minY = 0;
|
||||||
|
var maxY = image.height - 1;
|
||||||
|
var minX = 0;
|
||||||
|
var maxX = image.width - 1;
|
||||||
|
|
||||||
|
var found = false;
|
||||||
|
for (var y = 0; y < image.height; y++) {
|
||||||
|
for (var x = 0; x < image.width; x++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
minY = y;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false;
|
||||||
|
for (var y = image.height - 1; y >= minY; y--) {
|
||||||
|
for (var x = 0; x < image.width; x++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
maxY = y;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false;
|
||||||
|
for (var x = 0; x < image.width; x++) {
|
||||||
|
for (var y = minY; y <= maxY; y++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
minX = x;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false;
|
||||||
|
for (var x = image.width - 1; x >= minX; x--) {
|
||||||
|
for (var y = minY; y <= maxY; y++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
maxX = x;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newWidth = maxX - minX + 1;
|
||||||
|
final newHeight = maxY - minY + 1;
|
||||||
|
|
||||||
|
if (minY > 0 ||
|
||||||
|
maxY < image.height - 1 ||
|
||||||
|
minX > 0 ||
|
||||||
|
maxX < image.width - 1) {
|
||||||
|
if (newWidth > 10 && newHeight > 10) {
|
||||||
|
final cropped = img.copyCrop(
|
||||||
|
image,
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
);
|
||||||
|
final pngBytes = img.encodePng(cropped);
|
||||||
|
return _CropResult(pngBytes, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return const _CropResult(null, false);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:convert/convert.dart';
|
import 'package:convert/convert.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
@ -31,12 +33,42 @@ extension ShortCutsExtension on BuildContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveImageToGallery(Uint8List imageBytes) async {
|
Future<String?> saveImageToGallery(
|
||||||
|
Uint8List imageBytes, {
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) async {
|
||||||
|
var bytesToProcess = imageBytes;
|
||||||
|
|
||||||
|
if (createdAt != null) {
|
||||||
|
try {
|
||||||
|
final image = img.decodeImage(imageBytes);
|
||||||
|
if (image != null) {
|
||||||
|
final formattedDate = DateFormat(
|
||||||
|
'yyyy:MM:dd HH:mm:ss',
|
||||||
|
).format(createdAt);
|
||||||
|
image.exif.imageIfd[0x0132] = img.IfdValueAscii(
|
||||||
|
formattedDate,
|
||||||
|
); // DateTime
|
||||||
|
image.exif.exifIfd[0x9003] = img.IfdValueAscii(
|
||||||
|
formattedDate,
|
||||||
|
); // DateTimeOriginal
|
||||||
|
image.exif.exifIfd[0x9004] = img.IfdValueAscii(
|
||||||
|
formattedDate,
|
||||||
|
); // DateTimeDigitized
|
||||||
|
|
||||||
|
bytesToProcess = img.encodeJpg(image);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final jpgImages = await FlutterImageCompress.compressWithList(
|
final jpgImages = await FlutterImageCompress.compressWithList(
|
||||||
// ignore: avoid_redundant_argument_values
|
// ignore: avoid_redundant_argument_values
|
||||||
format: CompressFormat.jpeg,
|
format: CompressFormat.jpeg,
|
||||||
imageBytes,
|
bytesToProcess,
|
||||||
quality: 100,
|
quality: 100,
|
||||||
|
keepExif: true,
|
||||||
);
|
);
|
||||||
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
final confirmed = await showAlertDialog(
|
final confirmed = await showAlertDialog(
|
||||||
context,
|
context,
|
||||||
context.lang.deleteImageTitle,
|
context.lang.deleteImageTitle,
|
||||||
context.lang.deleteImageBody,
|
context.lang.deleteMemoriesBody(count),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
@ -239,7 +239,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
} else if (media.mediaFile.type == MediaType.image ||
|
} else if (media.mediaFile.type == MediaType.image ||
|
||||||
media.mediaFile.type == MediaType.gif) {
|
media.mediaFile.type == MediaType.gif) {
|
||||||
final imageBytes = await media.storedPath.readAsBytes();
|
final imageBytes = await media.storedPath.readAsBytes();
|
||||||
await saveImageToGallery(imageBytes);
|
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ class _SynchronizedImageViewerScreenState
|
||||||
} else if (item.mediaFile.type == MediaType.image ||
|
} else if (item.mediaFile.type == MediaType.image ||
|
||||||
item.mediaFile.type == MediaType.gif) {
|
item.mediaFile.type == MediaType.gif) {
|
||||||
final imageBytes = await item.storedPath.readAsBytes();
|
final imageBytes = await item.storedPath.readAsBytes();
|
||||||
await saveImageToGallery(imageBytes);
|
await saveImageToGallery(imageBytes, createdAt: item.mediaFile.createdAt);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:exif/exif.dart';
|
import 'package:exif/exif.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
@ -10,6 +11,7 @@ import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/android_photo_picker.service.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
||||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||||
|
|
@ -37,7 +39,106 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_checkPermissionAndLoad();
|
if (Platform.isAndroid) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_launchAndroidPicker();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_checkPermissionAndLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchAndroidPicker() async {
|
||||||
|
final uris = await AndroidPhotoPickerService.pickImages();
|
||||||
|
if (uris.isEmpty) {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = true;
|
||||||
|
_importProgress = 0;
|
||||||
|
_importStatus = context.lang.importGalleryStarting;
|
||||||
|
});
|
||||||
|
|
||||||
|
final total = uris.length;
|
||||||
|
var importedCount = 0;
|
||||||
|
var duplicated = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
for (final uri in uris) {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_importStatus = context.lang.importGalleryImportingOf(
|
||||||
|
importedCount + failedCount + 1,
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
_importProgress = (importedCount + failedCount) / total;
|
||||||
|
});
|
||||||
|
|
||||||
|
final bytes = await AndroidPhotoPickerService.getUriBytes(uri);
|
||||||
|
if (bytes == null) {
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hash = Uint8List.fromList(sha256.convert(bytes).bytes);
|
||||||
|
|
||||||
|
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
|
if (exsits.isNotEmpty) {
|
||||||
|
duplicated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get time from EXIF bytes, fallback to current time
|
||||||
|
final createdAt =
|
||||||
|
await getCreationTimeFromBytes(bytes) ?? DateTime.now();
|
||||||
|
|
||||||
|
const type = MediaType.image;
|
||||||
|
|
||||||
|
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||||
|
MediaFilesCompanion(
|
||||||
|
type: const Value(type),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
storedFileHash: Value(hash),
|
||||||
|
stored: const Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaFile != null) {
|
||||||
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
await mediaService.storedPath.parent.create(recursive: true);
|
||||||
|
await File(mediaService.storedPath.path).writeAsBytes(bytes);
|
||||||
|
|
||||||
|
await mediaService.calculateAndSaveSize();
|
||||||
|
await mediaService.createThumbnail();
|
||||||
|
unawaited(mediaService.cropTransparentBorders());
|
||||||
|
|
||||||
|
importedCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isImporting = false;
|
||||||
|
_importProgress = 1;
|
||||||
|
});
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
context.lang.importGalleryComplete(
|
||||||
|
importedCount,
|
||||||
|
duplicated,
|
||||||
|
failedCount,
|
||||||
|
),
|
||||||
|
level: SnackbarLevel.success,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkPermissionAndLoad() async {
|
Future<void> _checkPermissionAndLoad() async {
|
||||||
|
|
@ -166,6 +267,36 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> getCreationTimeFromBytes(Uint8List bytes) async {
|
||||||
|
final dates = <DateTime>[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await readExifFromBytes(bytes);
|
||||||
|
|
||||||
|
for (final key in data.keys) {
|
||||||
|
if (key.toLowerCase().contains('datetime') || key.contains('Time')) {
|
||||||
|
final time = data[key]?.printable;
|
||||||
|
if (time != null) {
|
||||||
|
try {
|
||||||
|
dates.add(
|
||||||
|
DateFormat('yyyy:MM:dd HH:mm:ss').parse(time),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore unparseable formats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore EXIF reading errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dates.isEmpty) return null;
|
||||||
|
|
||||||
|
// Return the oldest available date
|
||||||
|
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
void _toggleSelectAll() {
|
void _toggleSelectAll() {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (_selectedAssetIds.length == _assets.length) {
|
if (_selectedAssetIds.length == _assets.length) {
|
||||||
|
|
@ -248,6 +379,7 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
|
||||||
await mediaService.calculateAndSaveSize();
|
await mediaService.calculateAndSaveSize();
|
||||||
await mediaService.createThumbnail();
|
await mediaService.createThumbnail();
|
||||||
|
unawaited(mediaService.cropTransparentBorders());
|
||||||
|
|
||||||
importedCount++;
|
importedCount++;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -314,7 +446,9 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
color: Theme.of(context).cardColor,
|
color: Theme.of(context).cardColor,
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.1),
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).dividerColor.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.2.25+134
|
version: 0.2.26+135
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
|
||||||
BIN
test.jpg
Normal file
BIN
test.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 620 B |
Loading…
Reference in a new issue