From d03c42659c5195a7b5e292f4006efbd5ba22927e Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 31 May 2026 03:35:02 +0200 Subject: [PATCH] remove permissions again --- CHANGELOG.md | 2 +- android/app/src/main/AndroidManifest.xml | 2 - .../src/main/kotlin/eu/twonly/MainActivity.kt | 51 +++++ .../generated/app_localizations.dart | 6 + .../generated/app_localizations_de.dart | 11 + .../generated/app_localizations_en.dart | 11 + lib/src/localization/translations | 2 +- .../android_photo_picker.service.dart | 25 +++ .../mediafiles/mediafile.service.dart | 209 ++++++++++-------- lib/src/utils/misc.dart | 36 ++- .../visual/views/memories/memories.view.dart | 4 +- .../memories/synchronized_viewer.view.dart | 2 +- .../import_from_gallery.view.dart | 138 +++++++++++- pubspec.yaml | 2 +- test.jpg | Bin 0 -> 620 bytes 15 files changed, 396 insertions(+), 105 deletions(-) create mode 100644 lib/src/services/android_photo_picker.service.dart create mode 100644 test.jpg 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 0000000000000000000000000000000000000000..c91ed5daa7ebf028e1158c9cac567e51dfecc1ef GIT binary patch literal 620 zcmex=PfpQEif~-P{hK_8)fr;!& zg(60c6BlwQJ8e8D8g%i4ig8j=6DOCLxP+vXs+zinrk07RnYo3fm9vYho4bdnS8zyZ zSa?KaRB}pcT6#uiR&hybS$RceRdY*gTYE=m*QCi)rcRqaW9F9X@jO*zpr5PhGlvL#