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