remove permissions again
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled

This commit is contained in:
otsmr 2026-05-31 03:35:02 +02:00
parent 849f748968
commit d03c42659c
15 changed files with 396 additions and 105 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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,14 +449,70 @@ 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) {
if (result.isCropped && result.pngBytes != null) {
try {
final webpBytes = await FlutterImageCompress.compressWithList(
result.pngBytes!,
format: CompressFormat.webp,
quality: 90,
);
if (webpBytes.isNotEmpty) {
storedPath.writeAsBytesSync(webpBytes);
} else {
Log.warn('WebP compression returned empty, falling back to PNG');
storedPath.writeAsBytesSync(result.pngBytes!);
}
} 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( await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId, mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)), const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
); );
return; await updateFromDB();
} catch (e) {
Log.error(
'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e',
);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
await updateFromDB();
} }
}
}
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 minY = 0;
var maxY = image.height - 1; var maxY = image.height - 1;
@ -525,44 +583,9 @@ class MediaFileService {
height: newHeight, height: newHeight,
); );
final pngBytes = img.encodePng(cropped); final pngBytes = img.encodePng(cropped);
final webpBytes = await FlutterImageCompress.compressWithList( return _CropResult(pngBytes, true);
pngBytes,
format: CompressFormat.webp,
quality: 90,
);
storedPath.writeAsBytesSync(webpBytes);
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( return const _CropResult(null, false);
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
await updateFromDB();
} catch (e) {
Log.error(
'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e',
);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
await updateFromDB();
}
}
} }

View file

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

View file

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

View file

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

View file

@ -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,8 +39,107 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (Platform.isAndroid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_launchAndroidPicker();
});
} else {
_checkPermissionAndLoad(); _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 {
setState(() { setState(() {
@ -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),
), ),
), ),
), ),

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B