From 071be44851af35c193ba0e28ef042e9993f2d38d Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 20 Jul 2026 22:50:57 +0200 Subject: [PATCH] perf(android): R8, bitmap downscaling, edge-to-edge; recover device compat; publish to production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device compatibility (regression fix): - The CAMERA permission (from zxing_barcode_scanner / image_picker) implicitly required android.hardware.camera, excluding camera-less devices — Play showed Automotive -96%, Chromebook -86%, TV -25%. Declare camera & location uses-feature required="false" to keep those devices supported. - Detect a real camera at runtime (PackageManager.FEATURE_CAMERA_ANY via the existing MethodChannel), cache it at bootstrap, and hide the QR scan button and the camera photo-source option when absent, so no broken actions are offered. Play "app optimization" recommendations: - Enable R8 (isMinifyEnabled + isShrinkResources) with keep rules for the OCR (tesseract4android), SQLCipher and notifications JNI/reflection code. - Bitmap downscaling: cacheWidth/cacheHeight (and ResizeImage for avatars) on list thumbnails and avatars so photos decode to on-screen size, not full res. - Edge-to-edge: opt in with transparent system bars in main(). Release flow: - Tagged builds now publish to the production track at 100% (was internal); add a manual deploy_internal lane as a QA safety net. - Document country/region availability as a Play Console setting (not in-repo). --- apps/app_seeds/android/app/build.gradle.kts | 11 +++++ apps/app_seeds/android/app/proguard-rules.pro | 40 +++++++++++++++++++ .../android/app/src/main/AndroidManifest.xml | 13 ++++++ .../kotlin/org/comunes/tane/MainActivity.kt | 10 +++++ apps/app_seeds/fastlane/Fastfile | 15 ++++++- apps/app_seeds/lib/bootstrap.dart | 6 +++ apps/app_seeds/lib/main.dart | 11 +++++ .../lib/services/camera_availability.dart | 35 ++++++++++++++++ apps/app_seeds/lib/ui/calendar_screen.dart | 10 ++++- apps/app_seeds/lib/ui/draft_triage.dart | 6 +++ apps/app_seeds/lib/ui/market_widgets.dart | 10 ++++- apps/app_seeds/lib/ui/peer_avatar.dart | 14 ++++++- apps/app_seeds/lib/ui/photo_pick.dart | 31 ++++++++------ apps/app_seeds/lib/ui/qr_scan.dart | 9 ++++- apps/app_seeds/lib/ui/quick_add_sheet.dart | 2 + .../lib/ui/variety_detail_screen.dart | 6 +++ docs/release.md | 24 ++++++++++- 17 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 apps/app_seeds/android/app/proguard-rules.pro create mode 100644 apps/app_seeds/lib/services/camera_availability.dart diff --git a/apps/app_seeds/android/app/build.gradle.kts b/apps/app_seeds/android/app/build.gradle.kts index b0e4713..ecc470d 100644 --- a/apps/app_seeds/android/app/build.gradle.kts +++ b/apps/app_seeds/android/app/build.gradle.kts @@ -73,6 +73,17 @@ android { } else { signingConfigs.getByName("debug") } + // R8: shrink + optimize + obfuscate the Java/Kotlin bytecode and strip + // unused resources — Play's "app optimization" recommendation (smaller + // download, less memory). Native .so libs are untouched, so this does + // not change device/ABI compatibility. Keep rules for reflection/JNI + // heavy deps (OCR, SQLCipher, notifications) live in proguard-rules.pro. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } } diff --git a/apps/app_seeds/android/app/proguard-rules.pro b/apps/app_seeds/android/app/proguard-rules.pro new file mode 100644 index 0000000..3d061e3 --- /dev/null +++ b/apps/app_seeds/android/app/proguard-rules.pro @@ -0,0 +1,40 @@ +# R8/ProGuard keep rules for Tane (org.comunes.tane). +# +# R8 is enabled for release builds (see build.gradle.kts). It shrinks/optimizes +# the Java/Kotlin bytecode and can break code reached only via reflection or JNI +# — the native plugins below load their Java classes from C, so R8 can't see the +# references and would strip/rename them. Keep them explicitly. Start +# conservative; trim only after a release smoke test proves a class is unused. + +# --- Flutter engine & embedding (defensive; usually handled by the plugin) --- +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } +-dontwarn io.flutter.embedding.** + +# --- On-device OCR: tesseract4android (JNI) --- +# libtesseract/libleptonica call back into these Java classes by name. +-keep class com.googlecode.tesseract.android.** { *; } +-keep class com.googlecode.leptonica.android.** { *; } +-keep class io.paratoner.flutter_tesseract_ocr.** { *; } + +# --- QR scanning: zxing_barcode_scanner (pure ZXing, platform view) --- +-keep class com.shirisharyal.zxing_barcode_scanner.** { *; } + +# --- Encrypted DB: SQLCipher / sqlite3 native loader --- +# sqlite3 is reached over FFI (dlopen), but keep the loader plugin classes. +-keep class eu.simonbinder.sqlite3_flutter_libs.** { *; } +-keep class net.zetetic.** { *; } +-dontwarn net.zetetic.** + +# --- Local notifications --- +# Published keep rules for flutter_local_notifications' Gson-serialized models. +-keep class com.dexterous.** { *; } +-keep class com.google.gson.** { *; } +-keep class * extends com.google.gson.TypeAdapter +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn com.google.errorprone.annotations.** + +# --- Core library desugaring --- +-dontwarn java.lang.invoke.** +-dontwarn build.IgnoreJava8API diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml index 677d645..3ea677f 100644 --- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml +++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,19 @@ + + + + + + + when (call.method) { "getCoarseLatLon" -> getCoarseLatLon(result) + "hasCamera" -> result.success(hasCameraHardware()) else -> result.notImplemented() } } @@ -72,6 +73,15 @@ class MainActivity : FlutterActivity() { } } + /** + * Whether this device has any camera. Camera-less devices (Chromebooks, + * Android Automotive, many TVs) are supported — see AndroidManifest's + * uses-feature required="false" — so the QR scan and camera-capture UI + * hide themselves when this is false instead of offering broken actions. + */ + private fun hasCameraHardware(): Boolean = + packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + private fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission( this, diff --git a/apps/app_seeds/fastlane/Fastfile b/apps/app_seeds/fastlane/Fastfile index 6a52523..5f3437a 100644 --- a/apps/app_seeds/fastlane/Fastfile +++ b/apps/app_seeds/fastlane/Fastfile @@ -16,13 +16,24 @@ default_platform(:android) AAB_PATH = "build/app/outputs/bundle/release/app-release.aab".freeze platform :android do - desc "Upload the signed AAB + store listing to Google Play (internal track)" + desc "Upload the signed AAB + store listing to Google Play (production, 100%)" lane :deploy_play do + supply( + track: "production", + aab: AAB_PATH, + release_status: "completed", # publish to 100% of users (subject to review) + skip_upload_apk: true, # we ship the AAB, not a raw APK + skip_upload_changelogs: false, + ) + end + + desc "Upload the signed AAB to the internal test track (manual QA safety net)" + lane :deploy_internal do supply( track: "internal", aab: AAB_PATH, release_status: "completed", # internal testing has no review delay - skip_upload_apk: true, # we ship the AAB, not a raw APK + skip_upload_apk: true, skip_upload_changelogs: false, ) end diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 57ab427..e0ecc26 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -9,6 +9,7 @@ import 'data/variety_repository.dart'; import 'di/injector.dart'; import 'i18n/strings.g.dart'; import 'services/auto_backup_service.dart'; +import 'services/camera_availability.dart'; import 'services/coarse_location.dart'; import 'services/inbox_service.dart'; import 'services/locale_store.dart'; @@ -50,6 +51,11 @@ class _BootstrapState extends State { Future _boot() async { await configureDependencies(); + // Detect camera presence once, before the home screen builds, so camera-less + // devices (Chromebooks, Automotive, some TVs) hide the QR scan / camera UI + // instead of offering broken actions. Non-Android platforms keep the default. + await initCameraAvailability(); + // The saved language lives in the keystore, so it can only be applied once // DI is up. Until now the splash showed in the device language (it has no // text), so there is no visible flip. diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 0a4c2db..7509544 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'bootstrap.dart'; @@ -6,6 +7,16 @@ import 'ui/restart_widget.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + // Draw behind the system bars (edge-to-edge) with transparent bars, so the app + // fills the screen on Android 15+ (where edge-to-edge is enforced) and stays + // consistent elsewhere. Scaffolds use SafeArea to keep content off the insets. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Color(0x00000000), + systemNavigationBarColor: Color(0x00000000), + ), + ); // Start from the device language, then let an explicit in-app choice win — // it's the only reliable way to reach languages the OS picker doesn't offer // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. diff --git a/apps/app_seeds/lib/services/camera_availability.dart b/apps/app_seeds/lib/services/camera_availability.dart new file mode 100644 index 0000000..d9704e6 --- /dev/null +++ b/apps/app_seeds/lib/services/camera_availability.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Whether this device has any camera. Resolved once at startup (via the native +/// channel, `PackageManager.FEATURE_CAMERA_ANY`) and cached so `build()` methods +/// can read it synchronously — the QR scan button ([qrScanSupported]) and the +/// photo-source sheet hide their camera options when it is false. +/// +/// Defaults to `true` so a normal phone never loses camera UI over a transient +/// channel error; only genuinely camera-less devices (Chromebooks, Android +/// Automotive, some TVs — kept installable by the `uses-feature required=false` +/// in AndroidManifest) flip it to false. Non-Android platforms keep the default +/// (iOS devices have a camera; desktop/web gate camera UI elsewhere). +bool _deviceHasCamera = true; + +bool get deviceHasCamera => _deviceHasCamera; + +@visibleForTesting +set deviceHasCamera(bool value) => _deviceHasCamera = value; + +const _channel = MethodChannel('org.comunes.tane/coarse_location'); + +/// Queries the native camera-presence check and caches it. Called once during +/// bootstrap, before the first real frame, so synchronous readers see the right +/// value. Android-only; elsewhere the default stands. Never throws — a channel +/// error leaves the safe default in place. +Future initCameraAvailability() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + final has = await _channel.invokeMethod('hasCamera'); + if (has != null) _deviceHasCamera = has; + } catch (_) { + // Keep the default; a normal phone shouldn't lose camera UI over this. + } +} diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart index d01df9e..717fb5b 100644 --- a/apps/app_seeds/lib/ui/calendar_screen.dart +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -235,8 +235,14 @@ class _CalendarRow extends StatelessWidget { leading: CircleAvatar( radius: 20, backgroundColor: swatch.fill, - foregroundImage: - entry.photo == null ? null : MemoryImage(entry.photo!), + // Decode to the 40px avatar size (× dpr), not the full photo. + foregroundImage: entry.photo == null + ? null + : ResizeImage( + MemoryImage(entry.photo!), + width: (40 * MediaQuery.devicePixelRatioOf(context)).round(), + height: (40 * MediaQuery.devicePixelRatioOf(context)).round(), + ), child: entry.photo == null ? Text( entry.label.isEmpty diff --git a/apps/app_seeds/lib/ui/draft_triage.dart b/apps/app_seeds/lib/ui/draft_triage.dart index 9f677b1..1605e52 100644 --- a/apps/app_seeds/lib/ui/draft_triage.dart +++ b/apps/app_seeds/lib/ui/draft_triage.dart @@ -108,6 +108,10 @@ class _DraftTile extends StatelessWidget { photo, width: 48, height: 48, + cacheWidth: + (48 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: + (48 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), @@ -192,6 +196,8 @@ class _NameDraftDialogState extends State { child: Image.memory( widget.photo!, height: 140, + cacheHeight: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart index 20c9f31..92da91d 100644 --- a/apps/app_seeds/lib/ui/market_widgets.dart +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -36,6 +36,7 @@ class OfferThumbnail extends StatelessWidget { return ClipRRect( borderRadius: BorderRadius.circular(10), child: _offerImage( + context, url, semanticLabel: semanticLabel, width: size, @@ -63,7 +64,7 @@ class OfferHeroImage extends StatelessWidget { borderRadius: BorderRadius.circular(14), child: AspectRatio( aspectRatio: 4 / 3, - child: _offerImage(url, semanticLabel: semanticLabel), + child: _offerImage(context, url, semanticLabel: semanticLabel), ), ); } @@ -73,6 +74,7 @@ class OfferHeroImage extends StatelessWidget { /// remote URL (via network), always cropping to fill and falling back to a /// neutral placeholder — a broken image must never block the card. Widget _offerImage( + BuildContext context, String url, { required String semanticLabel, double? width, @@ -80,10 +82,16 @@ Widget _offerImage( }) { final inline = decodeDataUri(url); if (inline != null) { + // For a sized thumbnail, decode down to its on-screen pixels instead of the + // photo's full resolution (Play's "bitmap downscaling"). The hero image + // passes no width, so it decodes at native size as intended. + final dpr = MediaQuery.devicePixelRatioOf(context); return Image.memory( inline, width: width, height: height, + cacheWidth: width == null ? null : (width * dpr).round(), + cacheHeight: height == null ? null : (height * dpr).round(), fit: BoxFit.cover, semanticLabel: semanticLabel, errorBuilder: (context, _, _) => diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart index d501b9d..09bd3ef 100644 --- a/apps/app_seeds/lib/ui/peer_avatar.dart +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -34,7 +34,19 @@ class PeerAvatar extends StatelessWidget { if (pic.startsWith('data:')) { final bytes = decodeDataUri(pic); if (bytes != null) { - return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes)); + // Decode the photo down to the avatar's on-screen size (in physical + // pixels) instead of full resolution — the "bitmap downscaling" Play + // recommends. A tiny disc never needs a multi-megapixel decode. + final side = (radius * 2 * MediaQuery.devicePixelRatioOf(context)) + .round(); + return CircleAvatar( + radius: radius, + backgroundImage: ResizeImage( + MemoryImage(bytes), + width: side, + height: side, + ), + ); } } else if (pic.startsWith(avatarGlyphPrefix)) { final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length)); diff --git a/apps/app_seeds/lib/ui/photo_pick.dart b/apps/app_seeds/lib/ui/photo_pick.dart index f01f684..a7ae779 100644 --- a/apps/app_seeds/lib/ui/photo_pick.dart +++ b/apps/app_seeds/lib/ui/photo_pick.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../i18n/strings.g.dart'; +import '../services/camera_availability.dart'; /// Asks the user to take a photo or pick one from the gallery, then returns its /// bytes (or null if cancelled/unavailable). Camera failures — e.g. on desktop, @@ -16,12 +17,15 @@ Future pickPhoto(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - key: const Key('photo.source.camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: Text(t.photo.camera), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // Only offer the camera when the device actually has one; camera-less + // devices (Chromebooks, Automotive, some TVs) fall back to gallery. + if (deviceHasCamera) + ListTile( + key: const Key('photo.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( key: const Key('photo.source.gallery'), leading: const Icon(Icons.photo_library_outlined), @@ -57,12 +61,15 @@ Future> pickPhotos(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - key: const Key('photos.source.camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: Text(t.photo.camera), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // Only offer the camera when the device actually has one; camera-less + // devices (Chromebooks, Automotive, some TVs) fall back to gallery. + if (deviceHasCamera) + ListTile( + key: const Key('photos.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( key: const Key('photos.source.gallery'), leading: const Icon(Icons.photo_library_outlined), diff --git a/apps/app_seeds/lib/ui/qr_scan.dart b/apps/app_seeds/lib/ui/qr_scan.dart index 05126ed..f765d81 100644 --- a/apps/app_seeds/lib/ui/qr_scan.dart +++ b/apps/app_seeds/lib/ui/qr_scan.dart @@ -7,12 +7,17 @@ import 'package:zxing_barcode_scanner/zxing_barcode_scanner.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; +import '../services/camera_availability.dart'; import '../services/seed_label_scan.dart'; /// Whether this platform can open the camera scanner. Mobile-only for now /// (pure-ZXing platform views; no Google Play Services) — elsewhere the scan -/// button simply isn't offered, same pattern as the OCR capture. -bool get qrScanSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS); +/// button simply isn't offered, same pattern as the OCR capture. On Android it +/// also requires an actual camera, so camera-less devices (Chromebooks, +/// Automotive, some TVs) don't get a scan button that can't open — see +/// [deviceHasCamera]. +bool get qrScanSupported => + !kIsWeb && ((Platform.isAndroid && deviceHasCamera) || Platform.isIOS); /// Opens the full-screen camera scanner and returns the first decoded QR /// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 49574ab..349d247 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -199,6 +199,8 @@ class _MoreSection extends StatelessWidget { child: Image.memory( state.photoBytes!, height: 120, + cacheHeight: + (120 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 740713b..e9d6634 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -2244,10 +2244,16 @@ class _PhotoGalleryState extends State<_PhotoGallery> { itemBuilder: (_, i) => GestureDetector( key: Key('photo.thumb.$i'), onTap: () => _openPhotoViewer(context, cubit, i), + // Decode to the 140px thumbnail size (× dpr), not full photo + // resolution — the full-res decode happens in the viewer. child: Image.memory( photos[i].bytes, width: 140, height: 140, + cacheWidth: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/docs/release.md b/docs/release.md index 8b44a4f..3e70f01 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,7 +15,11 @@ git tag v0.1.0 && git push origin v0.1.0 ``` Forgejo Actions ([`../.forgejo/workflows/release.yml`](../.forgejo/workflows/release.yml)) -builds the signed AAB/APK and uploads to Play's **internal** track. No passwords are typed. +builds the signed AAB/APK and uploads to Play's **production** track at **100% +rollout**. No passwords are typed. Because a tag now publishes to everyone +(subject to Google review), run the [manual smoke test](#manual-smoke-test-before-shipping) +**before** tagging. To stage on the internal track first, run +`bundle exec fastlane deploy_internal` by hand. ## Versioning @@ -123,6 +127,24 @@ listing (titles, descriptions, changelogs, screenshots) is read straight from `fastlane/metadata/android/`. Data Safety and content-rating answers: [`legal/internal/play-compliance.md`](legal/internal/play-compliance.md). +Lanes: +- `deploy_play` — upload the AAB to **production** at 100% (what CI runs on tag). +- `deploy_internal` — same AAB to the **internal** test track (manual QA safety net). +- `deploy_metadata` — push only the store listing (no binary). +- `promote_production` — promote an internal release to production without a rebuild. + +### Country/region availability (Play Console, not this repo) + +Which countries the app is offered in is **not** in the repo and `fastlane supply` +does not manage it — it's a Play Console setting. To add countries: +**Play Console → (Tane) → Production → Countries / regions → Add countries/regions** +(under "Availability"). Play asks you to pick countries when you first move a +release to production; widen the list there for new markets. + +Note: the locales under `fastlane/metadata/android/` (`en-US`, `es-ES`) +are **listing languages**, not countries — adding a locale improves the store +page but does not by itself make the app available in a new country. + ## Publish to F-Droid (official repo) F-Droid builds from source after a merge request to `fdroiddata`. The build recipe