diff --git a/CHANGELOG.md b/CHANGELOG.md
index 498fe2de..bc2ef533 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## 0.2.25
+## 0.2.26
- New: Import images from the gallery
- Improved: Media files are now stored in the dedicated "twonly" album
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b50f9420..2f7732ad 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -82,8 +82,6 @@
-
-
diff --git a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt
index 96624f14..8b680aad 100644
--- a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt
+++ b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt
@@ -10,11 +10,32 @@ import android.content.Context
import io.crates.keyring.Keyring
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
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() {
+ private val CHANNEL = "eu.twonly/photo_picker"
+ private var pendingResult: MethodChannel.Result? = null
+
+ private lateinit var pickMultipleMedia: ActivityResultLauncher
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
+
+ pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
+ if (uris.isNotEmpty()) {
+ val uriStrings = uris.map { it.toString() }
+ pendingResult?.success(uriStrings)
+ } else {
+ pendingResult?.success(emptyList())
+ }
+ pendingResult = null
+ }
+
super.onCreate(savedInstanceState)
}
@@ -36,5 +57,35 @@ class MainActivity : FlutterFragmentActivity() {
Keyring.initializeNdkContext(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("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()
+ }
+ }
}
}
diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart
index c925b652..e9b1c99e 100644
--- a/lib/src/localization/generated/app_localizations.dart
+++ b/lib/src/localization/generated/app_localizations.dart
@@ -1484,6 +1484,12 @@ abstract class AppLocalizations {
/// **'The image will be irrevocably deleted.'**
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.
///
/// In en, this message translates to:
diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart
index f05887d5..715eb396 100644
--- a/lib/src/localization/generated/app_localizations_de.dart
+++ b/lib/src/localization/generated/app_localizations_de.dart
@@ -772,6 +772,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get settingsBackup => 'Backup';
diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart
index df106461..f379326c 100644
--- a/lib/src/localization/generated/app_localizations_en.dart
+++ b/lib/src/localization/generated/app_localizations_en.dart
@@ -766,6 +766,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get settingsBackup => 'Backup';
diff --git a/lib/src/localization/translations b/lib/src/localization/translations
index 1da4b1c1..189bf8f4 160000
--- a/lib/src/localization/translations
+++ b/lib/src/localization/translations
@@ -1 +1 @@
-Subproject commit 1da4b1c1bf172914fffad86d39a6ce4ca2845c01
+Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235
diff --git a/lib/src/services/android_photo_picker.service.dart b/lib/src/services/android_photo_picker.service.dart
new file mode 100644
index 00000000..d4380fdb
--- /dev/null
+++ b/lib/src/services/android_photo_picker.service.dart
@@ -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> pickImages() async {
+ try {
+ final result = await _channel.invokeListMethod('pickImages');
+ return result ?? [];
+ } catch (e) {
+ return [];
+ }
+ }
+
+ /// Reads the raw bytes from a content URI using the Android ContentResolver.
+ static Future getUriBytes(String uri) async {
+ try {
+ final bytes = await _channel.invokeMethod('getUriBytes', {'uri': uri});
+ return bytes;
+ } catch (e) {
+ return null;
+ }
+ }
+}
diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart
index deae9049..83528eab 100644
--- a/lib/src/services/mediafiles/mediafile.service.dart
+++ b/lib/src/services/mediafiles/mediafile.service.dart
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart';
@@ -310,6 +311,7 @@ class MediaFileService {
} else {
await saveImageToGallery(
storedPath.readAsBytesSync(),
+ createdAt: mediaFile.createdAt,
);
}
}
@@ -447,106 +449,41 @@ class MediaFileService {
try {
final bytes = storedPath.readAsBytesSync();
- final image = img.decodeImage(bytes);
- if (image == null) {
- await twonlyDB.mediaFilesDao.updateMedia(
- mediaFile.mediaId,
- const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
- );
- return;
- }
+ final result = await compute(_processImageCrop, bytes);
- 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);
+ if (result.isCropped && result.pngBytes != null) {
+ try {
final webpBytes = await FlutterImageCompress.compressWithList(
- pngBytes,
+ result.pngBytes!,
format: CompressFormat.webp,
quality: 90,
);
- storedPath.writeAsBytesSync(webpBytes);
-
- if (thumbnailPath.existsSync()) {
- thumbnailPath.deleteSync();
+
+ if (webpBytes.isNotEmpty) {
+ storedPath.writeAsBytesSync(webpBytes);
+ } else {
+ Log.warn('WebP compression returned empty, falling back to PNG');
+ storedPath.writeAsBytesSync(result.pngBytes!);
}
- 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;
+ } catch (e) {
+ Log.error('Error compressing to WebP, falling back to PNG: $e');
+ storedPath.writeAsBytesSync(result.pngBytes!);
}
+
+ 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(
@@ -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);
+}
diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart
index 30a359fc..b11db174 100644
--- a/lib/src/utils/misc.dart
+++ b/lib/src/utils/misc.dart
@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:math';
+
import 'package:clock/clock.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
@@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:gal/gal.dart';
+import 'package:image/image.dart' as img;
import 'package:intl/intl.dart';
import 'package:local_auth/local_auth.dart';
import 'package:provider/provider.dart';
@@ -31,12 +33,42 @@ extension ShortCutsExtension on BuildContext {
}
}
-Future saveImageToGallery(Uint8List imageBytes) async {
+Future 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(
// ignore: avoid_redundant_argument_values
format: CompressFormat.jpeg,
- imageBytes,
+ bytesToProcess,
quality: 100,
+ keepExif: true,
);
final hasAccess = await Gal.hasAccess(toAlbum: true);
if (!hasAccess) {
diff --git a/lib/src/visual/views/memories/memories.view.dart b/lib/src/visual/views/memories/memories.view.dart
index 93ff1577..12e47292 100644
--- a/lib/src/visual/views/memories/memories.view.dart
+++ b/lib/src/visual/views/memories/memories.view.dart
@@ -198,7 +198,7 @@ class MemoriesViewState extends State {
final confirmed = await showAlertDialog(
context,
context.lang.deleteImageTitle,
- context.lang.deleteImageBody,
+ context.lang.deleteMemoriesBody(count),
);
if (!confirmed) return;
@@ -239,7 +239,7 @@ class MemoriesViewState extends State {
} else if (media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif) {
final imageBytes = await media.storedPath.readAsBytes();
- await saveImageToGallery(imageBytes);
+ await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
}
}
}
diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart
index b7ebc2b8..19ec6f65 100644
--- a/lib/src/visual/views/memories/synchronized_viewer.view.dart
+++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart
@@ -197,7 +197,7 @@ class _SynchronizedImageViewerScreenState
} else if (item.mediaFile.type == MediaType.image ||
item.mediaFile.type == MediaType.gif) {
final imageBytes = await item.storedPath.readAsBytes();
- await saveImageToGallery(imageBytes);
+ await saveImageToGallery(imageBytes, createdAt: item.mediaFile.createdAt);
}
if (!mounted) return;
showSnackbar(
diff --git a/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart b/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart
index 1a7a71d8..d2d9b96d 100644
--- a/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart
+++ b/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
+import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart' show Value;
import 'package:exif/exif.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/src/database/tables/mediafiles.table.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/utils/misc.dart' show ShortCutsExtension, sha256File;
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
@@ -37,7 +39,106 @@ class _ImportFromGalleryViewState extends State {
@override
void initState() {
super.initState();
- _checkPermissionAndLoad();
+ if (Platform.isAndroid) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _launchAndroidPicker();
+ });
+ } else {
+ _checkPermissionAndLoad();
+ }
+ }
+
+ Future _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 _checkPermissionAndLoad() async {
@@ -166,6 +267,36 @@ class _ImportFromGalleryViewState extends State {
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
}
+ Future getCreationTimeFromBytes(Uint8List bytes) async {
+ final dates = [];
+
+ 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() {
setState(() {
if (_selectedAssetIds.length == _assets.length) {
@@ -248,6 +379,7 @@ class _ImportFromGalleryViewState extends State {
await mediaService.calculateAndSaveSize();
await mediaService.createThumbnail();
+ unawaited(mediaService.cropTransparentBorders());
importedCount++;
} else {
@@ -314,7 +446,9 @@ class _ImportFromGalleryViewState extends State {
color: Theme.of(context).cardColor,
border: Border(
bottom: BorderSide(
- color: Theme.of(context).dividerColor.withValues(alpha: 0.1),
+ color: Theme.of(
+ context,
+ ).dividerColor.withValues(alpha: 0.1),
),
),
),
diff --git a/pubspec.yaml b/pubspec.yaml
index 9c79f434..31b7386e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
-version: 0.2.25+134
+version: 0.2.26+135
environment:
sdk: ^3.11.0
diff --git a/test.jpg b/test.jpg
new file mode 100644
index 00000000..c91ed5da
Binary files /dev/null and b/test.jpg differ