diff --git a/.claude/context/server.md b/.claude/context/server.md index 555dd17..4108f5f 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -801,3 +801,5 @@ Flutter's own default to already be high enough. `isRestoringSession` still gates the splash screen until the above resolves — see `standards.md` for why widget tests must mock both storage channels rather than relying on this async path throwing naturally. + +- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`). diff --git a/.claude/context/standards.md b/.claude/context/standards.md index d46951d..5be131d 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -90,32 +90,6 @@ class/method already makes obvious. `Align`). - Run with `flutter test`. -## Play Store screenshots - -`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store -listing screenshots **without any real data**: it runs the real, unmodified app -against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented -content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives -it with `integration_test/store_screenshots_test.dart` (host-side driver: -`test_driver/integration_test.dart`, which saves `adb screencap` images). -Output lands in `store_listing/screenshots/` (gitignored) after -`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect -limits (each side 320-3840 px, long side at most 2x the short side - a stock -1080x2400 phone screen is 2.22:1 and would be rejected). - -- The test logs in by writing the demo user's account/app password into - storage via `AccountStore` before calling `main()`, so it skips the browser - login flow; it also fixes the theme (light, non-dynamic colour), the visible - tabs and the first-run notification prompt so shots are reproducible. -- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`), - tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two - `FilesView`s, and the fake content's names (defined in - `tool/demo_server/seed.py`) - keep those in sync if you rename either side. -- `flutter test` only runs `test/`, so this never runs as part of the normal - suite; `integration_test` is a dev-only dependency. -- Always review the images before uploading - see - `tool/demo_server/README.md` for the known places real data could appear. - ## Local install/deploy Never use `flutter install` to push a build to a test device — it always diff --git a/.claude/context/styling.md b/.claude/context/styling.md index fa09d7b..2f29e8a 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -30,10 +30,12 @@ widgets. Key points: OS-provided `ColorScheme` wins over the seed color — always thread both `dynamicScheme` and `useDynamicColor` through when adding a theme knob. - **Font**: Inter via `google_fonts`, applied through - `GoogleFonts.interTextTheme(...)`. In tests, set - `GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without - it, the font-fetch call to Google's CDN can stall `pumpAndSettle` - indefinitely (see `standards.md`). + `GoogleFonts.interTextTheme(...)`. Font files ship in + `assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and + `main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the + app never contacts Google (the privacy policy on the website relies on + this — keep it true; add new weights as bundled files). Tests set the + same flag in `setUpAll` (see `standards.md`). - **Cards**: flat (`elevation: 0`), 20px rounded corners, `surfaceContainerLow`. - **App bars**: flat, not centered, `surface` background. @@ -324,3 +326,13 @@ blocks are noted where they matter: generator step. `assets/icon/app_icon_monochrome.png` is deliberately a tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome layer), so it renders at a sensible size at 72-80px. + +## Text/markdown viewer + +`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. + +`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus. + +Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor. + +`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap. diff --git a/.gitignore b/.gitignore index bee0f47..fac1188 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,3 @@ app.*.map.json /android/app/profile /android/app/release -# Play Store screenshot tooling (tool/screenshots.sh) -/store_listing/screenshots/ -/tool/demo_server/.venv/ diff --git a/assets/google_fonts/InstrumentSans-Bold.ttf b/assets/google_fonts/InstrumentSans-Bold.ttf new file mode 100644 index 0000000..ac6bd9d Binary files /dev/null and b/assets/google_fonts/InstrumentSans-Bold.ttf differ diff --git a/assets/google_fonts/InstrumentSans-Medium.ttf b/assets/google_fonts/InstrumentSans-Medium.ttf new file mode 100644 index 0000000..9b00a52 Binary files /dev/null and b/assets/google_fonts/InstrumentSans-Medium.ttf differ diff --git a/assets/google_fonts/InstrumentSans-Regular.ttf b/assets/google_fonts/InstrumentSans-Regular.ttf new file mode 100644 index 0000000..177e4ec Binary files /dev/null and b/assets/google_fonts/InstrumentSans-Regular.ttf differ diff --git a/assets/google_fonts/InstrumentSans-SemiBold.ttf b/assets/google_fonts/InstrumentSans-SemiBold.ttf new file mode 100644 index 0000000..bf818ae Binary files /dev/null and b/assets/google_fonts/InstrumentSans-SemiBold.ttf differ diff --git a/assets/google_fonts/SchibstedGrotesk-Bold.ttf b/assets/google_fonts/SchibstedGrotesk-Bold.ttf new file mode 100644 index 0000000..cd2ef4a Binary files /dev/null and b/assets/google_fonts/SchibstedGrotesk-Bold.ttf differ diff --git a/assets/google_fonts/SchibstedGrotesk-ExtraBold.ttf b/assets/google_fonts/SchibstedGrotesk-ExtraBold.ttf new file mode 100644 index 0000000..2240823 Binary files /dev/null and b/assets/google_fonts/SchibstedGrotesk-ExtraBold.ttf differ diff --git a/assets/google_fonts/SchibstedGrotesk-Medium.ttf b/assets/google_fonts/SchibstedGrotesk-Medium.ttf new file mode 100644 index 0000000..eb3c6d7 Binary files /dev/null and b/assets/google_fonts/SchibstedGrotesk-Medium.ttf differ diff --git a/assets/google_fonts/SchibstedGrotesk-Regular.ttf b/assets/google_fonts/SchibstedGrotesk-Regular.ttf new file mode 100644 index 0000000..2c235ca Binary files /dev/null and b/assets/google_fonts/SchibstedGrotesk-Regular.ttf differ diff --git a/assets/google_fonts/SchibstedGrotesk-SemiBold.ttf b/assets/google_fonts/SchibstedGrotesk-SemiBold.ttf new file mode 100644 index 0000000..d62865a Binary files /dev/null and b/assets/google_fonts/SchibstedGrotesk-SemiBold.ttf differ diff --git a/integration_test/store_screenshots_test.dart b/integration_test/store_screenshots_test.dart deleted file mode 100644 index 0622abc..0000000 --- a/integration_test/store_screenshots_test.dart +++ /dev/null @@ -1,325 +0,0 @@ -// Drives the real app through its tabs and states against the fake-data demo -// server and captures the Play Store screenshots. Not part of the normal test -// suite (`flutter test` only runs `test/`) - run it through -// `tool/screenshots.sh`, which starts the demo server, an emulator and the -// driver in `test_driver/integration_test.dart` that saves the images. -// -// The app starts already logged in: the demo user's credentials are written -// straight into storage before `main()` runs, so Login Flow v2's browser -// step is skipped. -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:noo/main.dart' as app; -import 'package:noo/models/app_tab.dart'; -import 'package:noo/models/saved_account.dart'; -import 'package:noo/providers/files_controller.dart'; -import 'package:noo/providers/settings_controller.dart'; -import 'package:noo/providers/sync_status_controller.dart'; -import 'package:noo/services/account_store.dart'; -import 'package:noo/theme/design_tokens.dart'; -import 'package:noo/widgets/bottom_nav_bar.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -const _serverUrl = String.fromEnvironment( - 'DEMO_SERVER_URL', - defaultValue: 'http://localhost', -); -const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex'); -const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD'); - -void main() { - final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets( - 'Play Store screenshots', - (tester) async { - expect( - _appPassword, - isNotEmpty, - reason: 'Pass the demo user\'s app password via ' - '--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).', - ); - await _seedLoggedInStorage(); - - app.main(); - await tester.pump(); - await _pumpUntil(tester, find.byType(app.MainShellView)); - - // Android only: required before `takeScreenshot()` can capture - // anything at all. - if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage(); - - Future shoot(String name) async { - // Let scroll/transition animations and image fades finish first. - await tester.pump(const Duration(seconds: 1)); - await binding.takeScreenshot(name); - } - - // ---- 01 Files (list) ------------------------------------------------ - final filesTab = find.byKey(const ValueKey('files')); - await _pumpUntil( - tester, - find.descendant(of: filesTab, matching: find.text('Documents')), - ); - await shoot('01_Files'); - - // ---- 02 Files (grid, inside a folder of photos) -------------------- - final files = _read(tester); - await files.navigateToAbsoluteFolder('/Trips'); - await _pumpUntil( - tester, - find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')), - ); - // The controls row scrolls horizontally and doesn't fit every control - // on a narrow screen, so "Grid view" can start off past the clipped - // edge - not just off the *current* tab (which `.hitTestable()` alone - // would handle), but off the *visible part of this tab's own row*. - // `_tapInTab` scrolls it into view first. - await _tapInTab(tester, filesTab, find.byTooltip('Grid view')); - await tester.pump(const Duration(seconds: 4)); // thumbnails - await shoot('02_Files_Grid'); - - // ---- 07 File viewer ------------------------------------------------- - await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg')); - await tester.pump(const Duration(seconds: 4)); - await shoot('07_Viewer'); - tester.state(find.byType(Navigator).first).pop(); - await tester.pump(const Duration(seconds: 1)); - - // Back to the root folder, list view. - await _tapInTab(tester, filesTab, find.byTooltip('List view')); - await files.navigateToAbsoluteFolder('/'); - await tester.pump(const Duration(seconds: 2)); - - // ---- 03 Photos ------------------------------------------------------ - await _goToTab(tester, AppTab.photos); - await tester.pump(const Duration(seconds: 5)); // grid + thumbnails - await shoot('03_Photos'); - - // ---- 04 Favorites --------------------------------------------------- - await _goToTab(tester, AppTab.favorites); - await _pumpUntil(tester, find.text('Project brief.pdf')); - await shoot('04_Favorites'); - - // ---- 05 Activity ---------------------------------------------------- - await _goToTab(tester, AppTab.activity); - await tester.pump(const Duration(seconds: 3)); - await shoot('05_Activity'); - - // ---- 06 Shares ------------------------------------------------------ - await _goToTab(tester, AppTab.shares); - await tester.pump(const Duration(seconds: 3)); - await shoot('06_Shares'); - - // ---- 08 Sync status panel ------------------------------------------ - // Sync two folders to the device; the header chip flips to "Synced" - // once the native worker has finished, and tapping it opens the panel. - await _goToTab(tester, AppTab.files); - await _read( - tester, - ).addSyncedPaths({'/Documents': true, '/Trips': true}); - await _pumpUntil(tester, find.text('Synced'), timeout: 180); - await _tapInTab(tester, filesTab, find.text('Synced')); - await tester.pump(const Duration(seconds: 1)); - await shoot('08_Sync_Status'); - await _tapInTab(tester, filesTab, find.byTooltip('Collapse')); - - // ---- 09 Offline ----------------------------------------------------- - _setVisibleTabs(tester, [ - AppTab.files, - AppTab.photos, - AppTab.favorites, - AppTab.shares, - AppTab.offline, - ]); - await tester.pump(const Duration(milliseconds: 500)); - await _goToTab(tester, AppTab.offline); - await _pumpUntil( - tester, - find.descendant( - of: find.byKey(const ValueKey('offline')), - matching: find.text('Documents'), - ), - timeout: 120, - ); - await shoot('09_Offline'); - - // ---- 10/11 Settings ------------------------------------------------- - await _tap(tester, find.byTooltip('Settings').hitTestable().first); - await _pumpUntil(tester, find.text('Accounts')); - await shoot('10_Settings'); - // Wait for it, rather than assuming it's already built - ensureVisible - // itself has no retry/timeout, so on any transient delay (e.g. still - // mid-route-transition) it fails immediately instead of giving the - // frame a moment to catch up. - await _pumpUntil(tester, find.text('Files Cache')); - await tester.ensureVisible(find.text('Files Cache')); - await tester.pump(const Duration(milliseconds: 300)); - await shoot('11_Settings_Sync'); - tester.state(find.byType(Navigator).first).pop(); - await tester.pump(const Duration(seconds: 1)); - - // ---- 12/13 Dark theme ------------------------------------------------- - final settings = _read(tester); - settings.setThemeMode(ThemeMode.dark); - await _goToTab(tester, AppTab.files); - await tester.pump(const Duration(seconds: 2)); - await shoot('12_Files_Dark'); - await _goToTab(tester, AppTab.photos); - await tester.pump(const Duration(seconds: 3)); - await shoot('13_Photos_Dark'); - - // ---- 14/15 Dark theme, AMOLED (pure black) ----------------------------- - settings.setAmoledDark(true); - await tester.pump(const Duration(seconds: 1)); - await _goToTab(tester, AppTab.files); - await tester.pump(const Duration(seconds: 2)); - await shoot('14_Files_Dark_Amoled'); - await _goToTab(tester, AppTab.photos); - await tester.pump(const Duration(seconds: 3)); - await shoot('15_Photos_Dark_Amoled'); - }, - timeout: const Timeout(Duration(minutes: 15)), - ); -} - -/// Starts from empty storage, then writes exactly one saved account (the -/// demo user, with the app password from `--dart-define`) and the settings -/// that make screenshots reproducible: light theme, a fixed brand colour -/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav -/// tabs, and no first-run notification prompt. -Future _seedLoggedInStorage() async { - await const FlutterSecureStorage().deleteAll(); - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - - final store = AccountStore(); - final account = SavedAccount( - id: SavedAccount.makeId(_serverUrl, _username), - serverUrl: _serverUrl, - username: _username, - ); - await store.saveAccounts(prefs, [account]); - await store.saveActiveAccountId(prefs, account.id); - await store.writePassword(account.id, _appPassword); - - await prefs.setBool('notification_permission_asked', true); - await prefs.setString('ui_theme_mode', ThemeMode.light.name); - await prefs.setBool('ui_use_dynamic_color', false); - await prefs.setStringList('ui_tab_order', [ - for (final t in AppTab.values) t.name, - ]); - await prefs.setStringList('ui_hidden_tabs', [ - AppTab.trash.name, - AppTab.recent.name, - AppTab.offline.name, - ]); -} - -T _read(WidgetTester tester) => Provider.of( - tester.element(find.byType(app.MainShellView)), - listen: false, -); - -Future _tap(WidgetTester tester, Finder finder) async { - await tester.tap(finder); - await tester.pump(const Duration(milliseconds: 600)); -} - -/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) - -/// for chrome that's duplicated across tabs (`IndexedStack` keeps every -/// visible tab's widget tree mounted, not just the active one) and/or can be -/// scrolled out of its own row's clipped viewport (the controls row, -/// `FilesControlsRow`, doesn't fit every control on a narrow screen). -/// Scoping to [tab] picks the right one of several identical matches; -/// `ensureVisible` scrolls it into view before tapping, which plain -/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look -/// like "zero hit-testable matches" and throw the same unhelpful -/// `Bad state: No element`. -Future _tapInTab(WidgetTester tester, Finder tab, Finder matching) async { - final target = find.descendant(of: tab, matching: matching).first; - await tester.ensureVisible(target); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(target, warnIfMissed: false); - await tester.pump(const Duration(milliseconds: 600)); -} - -Future _goToTab(WidgetTester tester, AppTab tab) async { - // Only one bottom nav bar exists (outside the IndexedStack), so no - // cross-tab duplicate to worry about - but it can itself overflow a - // narrow screen, so still scroll the target into view rather than relying - // on hitTestable() alone (see `_tapInTab`'s doc comment). - final target = find - .descendant( - of: find.byType(BottomNavBar), - matching: find.byIcon(tab.icon), - ) - .first; - await tester.ensureVisible(target); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(target, warnIfMissed: false); - await tester.pump(const Duration(milliseconds: 600)); - - // Confirm the tap actually landed, rather than trusting it silently - a - // scroll-imprecise tap (see above) that misses its target doesn't throw, - // it just does nothing, which would otherwise leave every later - // screenshot quietly showing whatever tab was already selected instead of - // failing loudly. Every destination shows its label all the time now (see - // `BottomNavBar`'s doc comment), so label presence can't signal selection - // - the icon's color can: it's `accentText` only for the active tab. - await _pumpUntilTrue(tester, () { - if (target.evaluate().isEmpty) return false; - final icon = tester.widget(target); - final colors = tester.element(target).nooColors; - return icon.color == colors.accentText; - }, timeout: 10); -} - -/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the -/// five-tab cap is never hit). -void _setVisibleTabs(WidgetTester tester, List tabs) { - final settings = _read(tester); - for (final tab in AppTab.values) { - if (!tabs.contains(tab)) settings.setTabHidden(tab, true); - } - for (final tab in tabs) { - settings.setTabHidden(tab, false); - } -} - -/// Waits (in real time - this is a live binding, and the app is talking to a -/// real server) until [finder] matches something. -Future _pumpUntil( - WidgetTester tester, - Finder finder, { - int timeout = 60, -}) async { - final deadline = DateTime.now().add(Duration(seconds: timeout)); - while (finder.evaluate().isEmpty) { - if (DateTime.now().isAfter(deadline)) { - throw TestFailure('Timed out after ${timeout}s waiting for $finder'); - } - await tester.pump(const Duration(milliseconds: 250)); - } -} - -/// Like [_pumpUntil], but for a condition that isn't just "does a finder -/// match something" (e.g. a widget property). -Future _pumpUntilTrue( - WidgetTester tester, - bool Function() predicate, { - int timeout = 60, -}) async { - final deadline = DateTime.now().add(Duration(seconds: timeout)); - while (!predicate()) { - if (DateTime.now().isAfter(deadline)) { - throw TestFailure('Timed out after ${timeout}s waiting for condition'); - } - await tester.pump(const Duration(milliseconds: 250)); - } -} diff --git a/lib/main.dart b/lib/main.dart index 15b9448..4716603 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; @@ -44,6 +45,8 @@ import 'widgets/shell/shell_common.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + // Fonts ship in assets/google_fonts; never fetch them from Google at runtime. + GoogleFonts.config.allowRuntimeFetching = false; runApp( MultiProvider( // Split out of the former single `ServerProvider` god object - see diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index f7fe332..0aeffb7 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -147,6 +147,19 @@ class NextcloudService { return response.bodyBytes; } + /// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by + /// the in-app text editor. Returns whether the server accepted it. + Future putBytes(String itemPath, List bytes) async { + final response = await http.put( + Uri.parse(fileUrl(itemPath)), + headers: _headers, + body: bytes, + ); + return response.statusCode == 200 || + response.statusCode == 201 || + response.statusCode == 204; + } + Future testConnection() async { try { final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/'; diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index c7204a2..b43caae 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -206,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { style: NooText.title.copyWith(height: 1, color: colors.fg1), ), ), + const SizedBox(width: NooSpace.md), Expanded(child: androidTitleTrailing!), ] else Expanded( diff --git a/lib/widgets/noo/overlays/noo_share_parts.dart b/lib/widgets/noo/overlays/noo_share_parts.dart index 5125d6a..f4470d3 100644 --- a/lib/widgets/noo/overlays/noo_share_parts.dart +++ b/lib/widgets/noo/overlays/noo_share_parts.dart @@ -64,6 +64,8 @@ class NooPersonAccessRow extends StatelessWidget { final String permission; final VoidCallback? onPermissionTap; final String ownerLabel; + /// Replaces the owner label / permission pill (e.g. a plain search result). + final Widget? trailing; const NooPersonAccessRow({ super.key, @@ -74,6 +76,7 @@ class NooPersonAccessRow extends StatelessWidget { this.permission = 'Can edit', this.onPermissionTap, this.ownerLabel = 'Owner', + this.trailing, }); @override @@ -113,7 +116,9 @@ class NooPersonAccessRow extends StatelessWidget { ), ), const SizedBox(width: NooSpace.sm), - if (owner) + if (trailing != null) + trailing! + else if (owner) Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3)) else NooPermissionPill(label: permission, onTap: onPermissionTap), diff --git a/lib/widgets/noo/overlays/noo_sheet.dart b/lib/widgets/noo/overlays/noo_sheet.dart index eb1eafa..5d26978 100644 --- a/lib/widgets/noo/overlays/noo_sheet.dart +++ b/lib/widgets/noo/overlays/noo_sheet.dart @@ -106,40 +106,51 @@ class _NooSheetBodyState extends State<_NooSheetBody> { @override Widget build(BuildContext context) { - return SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - if (!_needsPeek) { - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: constraints.maxHeight * _kNooSheetPeekFraction, - ), - child: _buildScrollable(null), - ); - } - return DraggableScrollableSheet( - initialChildSize: _kNooSheetPeekFraction, - minChildSize: _kNooSheetMinFraction, - maxChildSize: _kNooSheetMaxFraction, - expand: false, - builder: (context, scrollController) { - // Plain `DraggableScrollableSheet` doesn't dismiss the modal - // route on its own when dragged down to its floor - it just - // stops resizing there - so pop explicitly once it's been - // dragged (near) all the way down, matching a native - // peek sheet's swipe-to-dismiss. - return NotificationListener( - onNotification: (notification) { - if (notification.extent <= notification.minExtent + 0.01) { - Navigator.of(context).maybePop(); - } - return false; - }, - child: _buildScrollable(scrollController), + // A modal bottom sheet doesn't avoid the keyboard itself, so a focused + // field would be covered. Inset by the keyboard and, while it's up, let + // the content use all the space above it. + final keyboard = MediaQuery.viewInsetsOf(context).bottom; + final keyboardUp = keyboard > 0; + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: SafeArea( + bottom: !keyboardUp, + child: LayoutBuilder( + builder: (context, constraints) { + if (!_needsPeek) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: keyboardUp + ? constraints.maxHeight + : constraints.maxHeight * _kNooSheetPeekFraction, + ), + child: _buildScrollable(null), ); - }, - ); - }, + } + return DraggableScrollableSheet( + initialChildSize: _kNooSheetPeekFraction, + minChildSize: _kNooSheetMinFraction, + maxChildSize: _kNooSheetMaxFraction, + expand: false, + builder: (context, scrollController) { + // Plain `DraggableScrollableSheet` doesn't dismiss the modal + // route on its own when dragged down to its floor - it just + // stops resizing there - so pop explicitly once it's been + // dragged (near) all the way down, matching a native + // peek sheet's swipe-to-dismiss. + return NotificationListener( + onNotification: (notification) { + if (notification.extent <= notification.minExtent + 0.01) { + Navigator.of(context).maybePop(); + } + return false; + }, + child: _buildScrollable(scrollController), + ); + }, + ); + }, + ), ), ); } diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index f89cb3d..12bd639 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -71,11 +72,13 @@ class ShareSheet extends StatefulWidget { return showNooSheet( context, children: [ - NooOverlayHeader( - leading: detailsFileTile(item), - title: item.name, - subtitle: detailsMetaLine(item), - onClose: () => Navigator.pop(context), + Builder( + builder: (sheetContext) => NooOverlayHeader( + leading: detailsFileTile(item), + title: item.name, + subtitle: detailsMetaLine(item), + onClose: () => Navigator.of(sheetContext).pop(), + ), ), ShareSheet(item: item), ], @@ -99,9 +102,36 @@ class _ShareSheetState extends State { bool _showInherited = false; final _peopleController = TextEditingController(); final _linkController = TextEditingController(); + final _peopleFocus = FocusNode(); + final _peopleKey = GlobalKey(); + + /// Once the user types, scrolls the sheet so the people field and its + /// results (under their title) sit near the top, leaving a little room under the sheet's + /// rounded top edge. + void _revealPeopleSection() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final ctx = _peopleKey.currentContext; + final box = ctx?.findRenderObject(); + if (ctx == null || box is! RenderBox) return; + final position = Scrollable.maybeOf(ctx)?.position; + final viewport = RenderAbstractViewport.maybeOf(box); + if (position == null || viewport == null) return; + final target = (viewport.getOffsetToReveal(box, 0).offset - 8).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + position.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); + } @override void dispose() { + _peopleFocus.dispose(); _peopleController.dispose(); _linkController.dispose(); super.dispose(); @@ -128,6 +158,7 @@ class _ShareSheetState extends State { return; } setState(() => _isSearching = true); + _revealPeopleSection(); final ops = context.read(); final results = await ops.searchSharees(trimmed); if (!mounted) return; @@ -135,6 +166,7 @@ class _ShareSheetState extends State { _searchResults = results; _isSearching = false; }); + _revealPeopleSection(); } /// The single "Name, email or group" field: reuses whichever match is @@ -364,6 +396,7 @@ class _ShareSheetState extends State { absorbing: _isAddingPerson, child: NooTextField( controller: _peopleController, + focusNode: _peopleFocus, leadingIcon: LucideIcons.search, placeholder: 'Name, email or group', onChanged: _search, @@ -381,17 +414,34 @@ class _ShareSheetState extends State { ), ), for (final sharee in _searchResults) - NooSettingsRow( - icon: sharee.type == ShareeType.group - ? LucideIcons.users - : LucideIcons.user, - label: Text(sharee.label), - subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null, + InkWell( + borderRadius: BorderRadius.circular(NooRadii.input), onTap: () => _addSharee(sharee), + child: NooPersonAccessRow( + avatar: NooAvatar( + initials: _initial(sharee.label), + color: NooPhotoTile.paletteColor(sharee.label.hashCode), + icon: sharee.type == ShareeType.group + ? LucideIcons.users + : null, + ), + name: sharee.label, + subtitle: sharee.subtitle, + trailing: const SizedBox.shrink(), + ), ), - const SizedBox(height: NooSpace.xs), + if (_searchResults.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: NooSpace.xs), + child: Divider(height: 1, thickness: 1, color: colors.line), + ) + else + const SizedBox(height: NooSpace.xs), NooPersonAccessRow( - avatar: NooAvatar(initials: _initial(session.username), current: true), + avatar: NooAvatar( + initials: _initial(session.username), + current: true, + ), name: session.username, owner: true, ), @@ -402,12 +452,16 @@ class _ShareSheetState extends State { color: NooPhotoTile.paletteColor( (share.sharedWithDisplayName ?? share.id).hashCode, ), - icon: share.shareType == ShareType.group ? LucideIcons.users : null, + icon: share.shareType == ShareType.group + ? LucideIcons.users + : null, ), name: share.sharedWithDisplayName ?? 'Shared', subtitle: share.shareType == ShareType.group ? 'Group' - : (share.shareType == ShareType.email ? 'Invited by email' : null), + : (share.shareType == ShareType.email + ? 'Invited by email' + : null), permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view', onPermissionTap: () => _openPersonMenu(share), ), @@ -429,7 +483,9 @@ class _ShareSheetState extends State { ), ), Icon( - _showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown, + _showInherited + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, size: 16, color: colors.fg3, ), @@ -464,7 +520,10 @@ class _ShareSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)), + Text( + 'Internal link', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), Text( 'For people who already have access', style: NooText.meta.copyWith(color: colors.fg3), @@ -559,6 +618,7 @@ class _ShareSheetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ NooShareSection( + key: _peopleKey, title: 'Share with people', child: _peopleSection(colors, session, peopleShares, internalLink), ), @@ -572,13 +632,16 @@ class _ShareSheetState extends State { caption: hasLink ? null : 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.', - child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(), + child: hasLink + ? _linkSection(publicLinkShares.first) + : const SizedBox.shrink(), ), if (!widget.item.isFolder) ...[ const SizedBox(height: 22), NooShareSection( title: 'Send file directly', - caption: "Link settings above don't apply - this sends the file's bytes directly.", + caption: + "Link settings above don't apply - this sends the file's bytes directly.", child: NooButton( variant: NooButtonVariant.outline, size: NooButtonSize.card, diff --git a/lib/widgets/viewer/media_text_preview.dart b/lib/widgets/viewer/media_text_preview.dart index 739fb3e..8dde92d 100644 --- a/lib/widgets/viewer/media_text_preview.dart +++ b/lib/widgets/viewer/media_text_preview.dart @@ -1,12 +1,25 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../models/nextcloud_item.dart'; import '../../providers/session_controller.dart'; import '../../theme/design_tokens.dart'; +import '../noo/core/noo_button.dart'; -/// Plain monospace text-file preview for [FileViewerScreen]'s static (non- -/// swipeable) preview path (`_textPreviewExtensions`). +/// Space the viewer's floating top bar and bottom action bar cover, so the +/// text sits clear of them (and the status bar) instead of scrolling +/// underneath. +const double _kTopBarClearance = 72; +const double _kActionBarClearance = 112; + +/// Text/markdown file preview and editor for [FileViewerScreen]'s static +/// (non-swipeable) path (`_textPreviewExtensions`). Content is always +/// editable when online; a Save button appears once it has changed. Markdown +/// files open rendered, with an Edit/Preview toggle. Opened +/// from the Offline tab ([localPathResolver] set) it is read-only, since the +/// local copy has no server to write back to. class MediaTextPreview extends StatefulWidget { final NextcloudItem item; final SessionController session; @@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget { } class _MediaTextPreviewState extends State { - String? _content; + final _controller = TextEditingController(); + String? _saved; String? _error; + bool _isSaving = false; + bool _editing = false; + + bool get _isMarkdown { + final name = widget.item.name.toLowerCase(); + return name.endsWith('.md') || name.endsWith('.markdown'); + } + + bool get _showRendered => _isMarkdown && !_editing; + + bool get _readOnly => widget.localPathResolver != null; + bool get _dirty => _saved != null && _controller.text != _saved; @override void initState() { super.initState(); + _controller.addListener(() => setState(() {})); _load(); } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + Future _load() async { try { final List bytes; @@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State { bytes = await widget.session.service!.fetchBytes(widget.item.path); } if (mounted) { - setState(() => _content = utf8.decode(bytes, allowMalformed: true)); + final text = utf8.decode(bytes, allowMalformed: true); + _controller.text = text; + setState(() => _saved = text); } } catch (e) { if (mounted) setState(() => _error = e.toString()); } } + Future _save() async { + final messenger = ScaffoldMessenger.of(context); + final text = _controller.text; + setState(() => _isSaving = true); + var ok = false; + try { + ok = await widget.session.service!.putBytes( + widget.item.path, + utf8.encode(text), + ); + } catch (_) {} + if (!mounted) return; + setState(() { + _isSaving = false; + if (ok) _saved = text; + }); + messenger.showSnackBar( + SnackBar( + content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'), + behavior: SnackBarBehavior.floating, + ), + ); + } + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -71,18 +130,103 @@ class _MediaTextPreviewState extends State { ), ); } - if (_content == null) { + if (_saved == null) { return Center(child: CircularProgressIndicator(color: colors.accent)); } + final safe = MediaQuery.paddingOf(context); + final style = NooText.mono.copyWith(color: colors.fg1, height: 1.5); + final padding = EdgeInsets.fromLTRB( + 20, + safe.top + _kTopBarClearance, + 20, + safe.bottom + _kActionBarClearance, + ); return Container( color: colors.surface, - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: SelectableText( - _content!, - style: NooText.mono.copyWith(color: colors.fg1, height: 1.5), - ), + child: Stack( + children: [ + Positioned.fill( + child: _showRendered + ? Markdown( + data: _controller.text, + padding: padding, + selectable: true, + styleSheet: _markdownStyle(context, colors), + onTapLink: (_, href, _) {}, + ) + : SingleChildScrollView( + padding: padding, + child: TextField( + controller: _controller, + readOnly: _readOnly, + maxLines: null, + keyboardType: TextInputType.multiline, + style: style, + cursorColor: colors.accentText, + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ), + if (_isMarkdown && !_readOnly) + Positioned( + right: 20, + top: safe.top + _kTopBarClearance - 8, + child: NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.compact, + icon: _editing ? LucideIcons.eye : LucideIcons.pencil, + onTap: () => setState(() => _editing = !_editing), + child: Text(_editing ? 'Preview' : 'Edit'), + ), + ), + if (_dirty) + Positioned( + right: 20, + bottom: safe.bottom + _kActionBarClearance, + child: NooButton( + size: NooButtonSize.card, + icon: LucideIcons.save, + disabled: _isSaving, + onTap: _isSaving ? null : _save, + child: Text(_isSaving ? 'Saving…' : 'Save'), + ), + ), + ], ), ); } } + +MarkdownStyleSheet _markdownStyle(BuildContext context, NooColors colors) { + final body = NooText.body.copyWith(color: colors.fg1, height: 1.55); + TextStyle heading(double size) => + NooText.title.copyWith(color: colors.fg1, fontSize: size, height: 1.25); + return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( + p: body, + h1: heading(26), + h2: heading(22), + h3: heading(19), + h4: heading(17), + listBullet: body, + a: body.copyWith(color: colors.accentText), + blockquote: body.copyWith(color: colors.fg2), + blockquoteDecoration: BoxDecoration( + border: Border(left: BorderSide(color: colors.line, width: 3)), + ), + code: NooText.mono.copyWith( + color: colors.fg1, + backgroundColor: colors.surface2, + ), + codeblockDecoration: BoxDecoration( + color: colors.surface2, + borderRadius: BorderRadius.circular(NooRadii.input), + ), + horizontalRuleDecoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.line)), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index 8622330..5a200eb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -254,11 +254,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -267,6 +262,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 + url: "https://pub.dev" + source: hosted + version: "1.0.12" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -333,11 +336,6 @@ packages: description: flutter source: sdk version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" google_fonts: dependency: "direct main" description: @@ -378,11 +376,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" intl: dependency: "direct main" description: @@ -503,6 +496,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.20" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" marquee: dependency: "direct main" description: @@ -791,14 +792,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - process: - dependency: transitive - description: - name: process - sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" - url: "https://pub.dev" - source: hosted - version: "5.0.6" provider: dependency: "direct main" description: @@ -932,14 +925,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" synchronized: dependency: transitive description: @@ -1084,14 +1069,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" - url: "https://pub.dev" - source: hosted - version: "3.2.0" webview_flutter: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5edc5b7..99d9697 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.1+2 +version: 1.0.0+3 environment: sdk: ^3.12.2 @@ -58,16 +58,12 @@ dependencies: webview_flutter: ^4.14.1 permission_handler: ^11.3.1 connectivity_plus: ^6.1.5 + flutter_markdown_plus: ^1.0.12 dev_dependencies: flutter_test: sdk: flutter - # Drives tool/screenshots.sh (Play Store screenshots against a fake-data - # demo server) - dev only, never part of the release build. - integration_test: - sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your @@ -89,6 +85,7 @@ flutter: assets: - assets/icon/app_icon.png - assets/icon/app_icon_monochrome.png + - assets/google_fonts/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart deleted file mode 100644 index 9cc6fd1..0000000 --- a/test_driver/integration_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -// Host-side driver for integration_test/store_screenshots_test.dart (run via -// tool/screenshots.sh). Each time the test asks for a screenshot this saves -// a PNG named after it into $SCREENSHOT_DIR. -// -// These bytes are the Flutter surface only (no OS status bar) - an earlier -// version of this file tried to re-capture the screen with `adb exec-out -// screencap` for a "real" screenshot including it, but on Android, -// `onScreenshot` only fires once the whole test has finished (see -// `IntegrationTestWidgetsFlutterBinding`/`integrationDriver`'s source: the -// native platform channel hands screenshot bytes straight to `reportData` -// with no round trip to the driver, and the driver's first `requestData` -// call blocks until every `testWidgets` body in the file has returned) - so -// every `adb screencap` call was capturing the same final frame, regardless -// of which named screenshot it was meant to be. Saving these bytes directly -// is what actually gives each name its own distinct image. -import 'dart:io'; - -import 'package:integration_test/integration_test_driver_extended.dart'; - -Future main() async { - final outDir = Directory( - Platform.environment['SCREENSHOT_DIR'] ?? 'store_listing/screenshots', - )..createSync(recursive: true); - - await integrationDriver( - onScreenshot: - (String name, List bytes, [Map? args]) async { - File('${outDir.path}/$name.png').writeAsBytesSync(bytes); - stdout.writeln('saved $name.png'); - return true; - }, - ); -} diff --git a/tool/demo_server/README.md b/tool/demo_server/README.md deleted file mode 100644 index 2c04911..0000000 --- a/tool/demo_server/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Demo server for Play Store screenshots - -`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway -Nextcloud that only contains fake data**, so nothing real can appear in them. -This folder is that server: - -- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing - persisted; `docker compose down -v` wipes it). -- `seed.py` - creates the users and fills them with invented content: landscape - "photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites, - shares (to a second user and a public link), trash, a filler file for the - storage quota, and edits by a second user so the Activity feed has more than - one author. It ends by printing `APP_PASSWORD=...` - an app password for the - demo user, so the app can start logged in without the browser login step. - -## Running it - -Most of the time just run the whole pipeline from the repo root: - -```bash -bash tool/screenshots.sh -``` - -(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter** -and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed -into `tool/demo_server/.venv` automatically if your Python lacks them. The -script installs the emulator and a system image through `sdkmanager` on first -use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`, -`ANDROID_SERIAL`, ...) are documented at the top of the script. - -To poke at the server yourself: - -```bash -docker compose -f tool/demo_server/docker-compose.yml up -d -python3 tool/demo_server/seed.py # prints APP_PASSWORD=... -# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password) -docker compose -f tool/demo_server/docker-compose.yml down -v -``` - -`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated -files to `DIR` without any server - handy for previewing the fake content. - -## Before you upload - -Look at every image. The known places where real data could still show up: -the server address and username (sync header panel, Settings > Accounts), the -avatar initial, notification icons in the status bar, and anything left over -from a previously used emulator profile (the script always wipes its own). diff --git a/tool/demo_server/docker-compose.yml b/tool/demo_server/docker-compose.yml deleted file mode 100644 index dca4427..0000000 --- a/tool/demo_server/docker-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ -# A throwaway Nextcloud that only ever holds fake data - the backend the Play -# Store screenshots are taken against (see tool/screenshots.sh). Nothing is -# persisted: `docker compose down -v` wipes it completely. -services: - nextcloud: - image: ${NEXTCLOUD_IMAGE:-nextcloud:apache} - ports: - - "${DEMO_PORT:-80}:80" - environment: - # SQLite keeps this to a single container. - SQLITE_DATABASE: nextcloud - NEXTCLOUD_ADMIN_USER: admin - NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password - # `localhost` is how the Android emulator reaches this server (via - # `adb reverse`); DEMO_HOST is the optional friendlier hostname. - NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}" diff --git a/tool/demo_server/requirements.txt b/tool/demo_server/requirements.txt deleted file mode 100644 index ff99c38..0000000 --- a/tool/demo_server/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests>=2.31 -Pillow>=10.0 diff --git a/tool/demo_server/seed.py b/tool/demo_server/seed.py deleted file mode 100644 index 203e2c0..0000000 --- a/tool/demo_server/seed.py +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 -"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots. - -Everything here is invented or procedurally generated (landscape "photos" -drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real - -and nothing with a licence or a face in it - can end up in a screenshot. - - python3 seed.py seed http://localhost (DEMO_BASE_URL) - python3 seed.py --generate-only D write the fake files under D, no server - -On success the LAST line printed to stdout is `APP_PASSWORD=` - an app -password for the demo user, which tool/screenshots.sh hands to the app so it -can start logged in without going through the browser login flow. All -progress output goes to stderr. -""" -import argparse -import io -import math -import os -import random -import shutil -import subprocess -import sys -import tempfile -import time -from urllib.parse import quote - -from PIL import Image, ImageDraw, ImageFilter - -BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/") -ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password")) -FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300")) - -# The account the screenshots are taken as, and a second user to share with. -USERS = { - "Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"), - "Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"), -} - - -def log(message): - print(message, file=sys.stderr, flush=True) - - -# --------------------------------------------------------------- content --- - -# (sky top, sky bottom, sun, far hills, mid hills, near hills) -PALETTES = [ - ((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)), - ((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)), - ((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)), - ((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)), - ((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)), - ((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)), - ((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)), - ((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)), -] - - -def _mix(a, b, t): - return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) - - -def landscape(seed, size=(1600, 1067)): - """A soft, layered-hills landscape - reads as a photo at thumbnail size.""" - rnd = random.Random(seed) - top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)] - w, h = size - img = Image.new("RGB", size) - draw = ImageDraw.Draw(img) - for y in range(h): - draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75)))) - sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7) - draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun) - for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)): - phase = [rnd.uniform(0, math.tau) for _ in range(3)] - freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)] - weight = (1.0, 0.5, 0.2) - pts = [(0, h)] - for x in range(0, w + 8, 8): - v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase)) - pts.append((x, int(h * base + h * amp * v / 1.7))) - pts.append((w, h)) - draw.polygon(pts, fill=color) - img = img.filter(ImageFilter.GaussianBlur(0.8)) - out = io.BytesIO() - img.save(out, "JPEG", quality=86) - return out.getvalue() - - -def pdf(title, lines): - """A tiny but valid one-page PDF.""" - - def esc(s): - return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") - - body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title) - for i, line in enumerate(lines): - body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line)) - stream = body.encode("latin-1", "replace") - objs = [ - b"<< /Type /Catalog /Pages 2 0 R >>", - b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", - b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R " - b"/Resources << /Font << /F1 5 0 R >> >> >>", - b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream", - b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", - ] - out = bytearray(b"%PDF-1.4\n") - offsets = [] - for i, obj in enumerate(objs, start=1): - offsets.append(len(out)) - out += b"%d 0 obj\n" % i + obj + b"\nendobj\n" - xref = len(out) - out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) - for off in offsets: - out += b"%010d 00000 n \n" % off - out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) - return bytes(out) - - -def text(body): - return body.strip().encode("utf-8") + b"\n" - - -def video(): - """A short test-pattern clip if ffmpeg is around, else None (skipped).""" - if not shutil.which("ffmpeg"): - return None - with tempfile.TemporaryDirectory() as tmp: - path = os.path.join(tmp, "clip.mp4") - result = subprocess.run( - ["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24", - "-pix_fmt", "yuv420p", path], - capture_output=True, - ) - if result.returncode != 0: - return None - with open(path, "rb") as f: - return f.read() - - -def build_content(): - """(path, bytes) for everything the demo user owns, plus what to favorite/delete.""" - files = [] - - def add(path, data): - if data is not None: - files.append((path, data)) - - add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go.")) - add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter")) - - add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"])) - add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"])) - add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite")) - add("/Documents/Budget 2026.xlsx", os.urandom(24_000)) - add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"])) - add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"])) - - trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"] - for i, name in enumerate(trips): - add("/Trips/%s.jpg" % name, landscape(i + 1)) - add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"])) - - for i in range(8): - add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i)) - add("/Photos/Summer 2026/Clip - hike.mp4", video()) - - add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper.")) - add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt.")) - add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans")) - - add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"])) - add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}')) - add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets.")) - - favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"] - - # Created, then deleted - they end up in the Trash tab. - trashed = [ - ("/Old draft.txt", text("Superseded draft.")), - ("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))), - ("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])), - ] - return files, favorites, trashed - - -# ---------------------------------------------------------------- server --- - - -class ZeroStream: - """`size` zero bytes, streamed - a big filler file without holding it in memory.""" - - def __init__(self, size): - self.size, self.pos = size, 0 - - def __len__(self): - return self.size - - def read(self, n=-1): - n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos) - self.pos += n - return b"\0" * n - - -class Server: - def __init__(self): - import requests # imported lazily so --generate-only needs no network libs - - self.requests = requests - - def _call(self, method, url, auth, **kw): - headers = kw.pop("headers", {}) - headers.setdefault("OCS-APIRequest", "true") - return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw) - - def wait_ready(self): - log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...") - deadline = time.time() + 600 - while time.time() < deadline: - try: - r = self.requests.get(BASE + "/status.php", timeout=5) - if r.ok and r.json().get("installed") and not r.json().get("maintenance"): - return - except Exception: - pass - time.sleep(3) - sys.exit("Nextcloud did not become ready in time") - - def ocs(self, method, path, auth, **kw): - r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw) - try: - return r.json()["ocs"] - except Exception: - return {"meta": {"statuscode": r.status_code}, "data": {}} - - def create_user(self, user, password, display, email, quota): - res = self.ocs( - "POST", "/ocs/v1.php/cloud/users", ADMIN, - data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota}, - ) - code = res["meta"]["statuscode"] - if code not in (100, 200, 102): # 102 = already exists - sys.exit("Could not create user %s: %s" % (user, res)) - - def dav(self, method, user, path, **kw): - auth = (user, USERS[user][0]) - url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path)) - return self._call(method, url, auth, **kw) - - def mkdirs(self, user, path): - built = "" - for part in [p for p in path.split("/") if p]: - built += "/" + part - self.dav("MKCOL", user, built) - - def put(self, user, path, data): - parent = path.rsplit("/", 1)[0] - if parent: - self.mkdirs(user, parent) - r = self.dav("PUT", user, path, data=data) - if r.status_code not in (200, 201, 204): - sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200])) - - def favorite(self, user, path): - body = ( - '' - "1" - ) - self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"}) - - def delete(self, user, path): - self.dav("DELETE", user, path) - - def share(self, user, path, share_type, share_with=None, permissions=31): - data = {"path": path, "shareType": share_type, "permissions": permissions} - if share_with: - data["shareWith"] = share_with - auth = (user, USERS[user][0]) - self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data) - - def app_password(self, user): - auth = (user, USERS[user][0]) - res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth) - token = (res.get("data") or {}).get("apppassword") - if not token: - sys.exit("Could not get an app password for %s: %s" % (user, res)) - return token - - -def seed_server(): - server = Server() - server.wait_ready() - - for user, (password, display, email, quota) in USERS.items(): - log("Creating user %s" % user) - server.create_user(user, password, display, email, quota) - - files, favorites, trashed = build_content() - log("Uploading %d files as Alex" % len(files)) - for path, data in files: - server.put("Alex", path, data) - if FILLER_MB > 0: - log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB) - server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024)) - - for path in favorites: - server.favorite("Alex", path) - - log("Creating shares") - server.share("Alex", "/Trips", 0, "Sam", permissions=31) - server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1) - server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch.")) - server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30.")) - server.share("Sam", "/Team notes", 0, "Alex", permissions=31) - # Sam edits inside the folder Alex shared - gives the Activity feed a second author. - server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!")) - - log("Filling the trash") - for path, data in trashed: - server.put("Alex", path, data) - for path, _ in trashed: - server.delete("Alex", path) - server.delete("Alex", "/Receipts (old)") - - token = server.app_password("Alex") - print("APP_PASSWORD=" + token, flush=True) - - -def generate_only(target): - files, favorites, trashed = build_content() - for path, data in files + trashed: - dest = os.path.join(target, "Alex" + path) - os.makedirs(os.path.dirname(dest), exist_ok=True) - with open(dest, "wb") as f: - f.write(data) - log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites))) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server") - args = parser.parse_args() - if args.generate_only: - generate_only(args.generate_only) - else: - seed_server() diff --git a/tool/finalize_screenshots.py b/tool/finalize_screenshots.py deleted file mode 100644 index bfe3613..0000000 --- a/tool/finalize_screenshots.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -"""Makes captured screenshots Play Store ready and reports anything that isn't. - -Play wants JPEG or 24-bit PNG (no alpha), each side 320-3840 px, and the long -side at most twice the short side. The captured PNGs are 32-bit RGBA, so this -flattens them to RGB in place, then checks the dimensions. - - python3 tool/finalize_screenshots.py store_listing/screenshots -""" -import sys -from pathlib import Path - -from PIL import Image - - -def main(directory): - files = sorted(Path(directory).glob("*.png")) - if not files: - sys.exit("No PNGs found in %s" % directory) - - problems = 0 - for path in files: - with Image.open(path) as im: - im.load() - rgb = Image.new("RGB", im.size, (255, 255, 255)) - rgb.paste(im, mask=im.getchannel("A") if "A" in im.getbands() else None) - rgb.save(path, "PNG", optimize=True) - - w, h = rgb.size - notes = [] - if min(w, h) < 320 or max(w, h) > 3840: - notes.append("side outside 320-3840 px") - if max(w, h) > 2 * min(w, h): - notes.append("aspect ratio over 2:1") - problems += bool(notes) - print("%-32s %4dx%-4d %s" % (path.name, w, h, "; ".join(notes) or "ok")) - - print("\n%d screenshots, %d with problems" % (len(files), problems)) - sys.exit(1 if problems else 0) - - -if __name__ == "__main__": - main(sys.argv[1] if len(sys.argv) > 1 else "store_listing/screenshots") diff --git a/tool/screenshots.sh b/tool/screenshots.sh deleted file mode 100644 index d85170a..0000000 --- a/tool/screenshots.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env bash -# Generates the Play Store screenshots - without any real data. -# -# What it does, end to end: -# 1. starts a throwaway Nextcloud in Docker and fills it with fake data -# 2. creates + boots a clean Android emulator (1080x1920, demo-mode status bar) -# 3. runs integration_test/store_screenshots_test.dart against it, which -# logs in as the fake user and walks through the app's tabs and states -# 4. leaves flattened, size-checked PNGs in store_listing/screenshots/ -# -# Works on Linux, macOS and Git Bash on Windows. Needs: docker, python3, -# flutter, and an Android SDK (ANDROID_HOME / ANDROID_SDK_ROOT). See -# tool/demo_server/README.md. -# -# Options (environment variables): -# SCREENSHOT_DIR output directory (store_listing/screenshots) -# ANDROID_SERIAL use this already-running device instead of the emulator -# HEADLESS=1 run the emulator without a window -# DEMO_PORT host port for the demo server (80; anything else shows -# up as ":port" in the app's header) -# DEMO_HOST show this hostname instead of "localhost" in the app, -# e.g. cloud.example.com (best effort - edits the -# emulator's hosts file, which needs a rootable image) -# KEEP_SERVER=1 leave the demo server running afterwards -# KEEP_EMULATOR=1 leave the emulator running afterwards -# SCREEN_W / SCREEN_H / SCREEN_DENSITY emulator screen (1080 / 1920 / 440); -# Play needs the long side <= 2x the short side -set -euo pipefail - -cd "$(dirname "$0")/.." -REPO_ROOT="$PWD" - -# Git Bash would otherwise rewrite device paths such as /system/etc/hosts. -export MSYS_NO_PATHCONV=1 - -AVD_NAME="${AVD_NAME:-noo_screenshots}" -API_LEVEL="${SCREENSHOT_API:-36}" -SYSTEM_IMAGE="system-images;android-${API_LEVEL};google_apis;x86_64" -SCREEN_W="${SCREEN_W:-1080}" -SCREEN_H="${SCREEN_H:-1920}" -SCREEN_DENSITY="${SCREEN_DENSITY:-440}" -OUT_DIR="${SCREENSHOT_DIR:-store_listing/screenshots}" -DEMO_PORT="${DEMO_PORT:-80}" -DEMO_HOST="${DEMO_HOST:-}" -export DEMO_PORT DEMO_HOST - -log() { printf '\n==> %s\n' "$*" >&2; } -die() { printf 'error: %s\n' "$*" >&2; exit 1; } - -# Two easy mistakes worth catching up front rather than as a confusing -# "missing prerequisites" list. -if [ "$(id -u)" = 0 ]; then - die "don't run this as root/with sudo - it would create root-owned files and use root's home for the emulator." -fi -if grep -qi microsoft /proc/version 2>/dev/null; then - die "this is WSL, which can't see your Windows Flutter/Android SDK (and can't run the emulator well). On Windows run it from Git Bash instead." -fi - -# ---------------------------------------------------------------- tooling --- - -# Windows env vars hold C:\... paths; convert for bash. -to_unix() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1"; else printf '%s' "$1"; fi; } - -SDK_ROOT="" -for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" \ - "$HOME/Library/Android/sdk" "${LOCALAPPDATA:-}/Android/Sdk"; do - if [ -n "$candidate" ] && [ -d "$(to_unix "$candidate")" ]; then - SDK_ROOT="$(to_unix "$candidate")" - break - fi -done - -# Finds a tool by name on PATH or in the SDK, including Windows .bat/.exe. -find_tool() { - local name="$1" dir ext - for ext in "" .bat .exe; do - if command -v "$name$ext" >/dev/null 2>&1; then command -v "$name$ext"; return 0; fi - done - for dir in "$SDK_ROOT/cmdline-tools/latest/bin" "$SDK_ROOT/platform-tools" "$SDK_ROOT/emulator"; do - for ext in "" .bat .exe; do - if [ -x "$dir/$name$ext" ]; then printf '%s' "$dir/$name$ext"; return 0; fi - done - done - return 1 -} - -missing=() -require() { # - command -v "$1" >/dev/null 2>&1 || missing+=("$1 - $2") -} -require docker "install Docker (Desktop on Windows/macOS)" -require flutter "install Flutter and put it on PATH" -PY="$(command -v python3 || command -v python || true)" -[ -n "$PY" ] || missing+=("python3 - install Python 3") -ADB="$(find_tool adb || true)" -[ -n "$ADB" ] || missing+=("adb - install Android platform-tools (or set ANDROID_HOME)") -if [ -z "${ANDROID_SERIAL:-}" ]; then - SDKMANAGER="$(find_tool sdkmanager || true)" - AVDMANAGER="$(find_tool avdmanager || true)" - [ -n "$SDKMANAGER" ] && [ -n "$AVDMANAGER" ] || - missing+=("sdkmanager/avdmanager - install the Android cmdline-tools (or set ANDROID_HOME)") -fi -if [ "${#missing[@]}" -gt 0 ]; then - printf 'Missing prerequisites:\n' >&2 - printf ' - %s\n' "${missing[@]}" >&2 - exit 1 -fi - -if docker compose version >/dev/null 2>&1; then - DC=(docker compose) -else - DC=(docker-compose) -fi -DC+=(-f tool/demo_server/docker-compose.yml) -adb() { "$ADB" "$@"; } - -# Python with Pillow + requests: use the system one if it has them, else a -# local virtualenv (tool/demo_server/.venv, gitignored). -if ! "$PY" -c 'import PIL, requests' >/dev/null 2>&1; then - log "Setting up a Python virtualenv (Pillow, requests)" - "$PY" -m venv tool/demo_server/.venv - if [ -x tool/demo_server/.venv/bin/python ]; then - PY="$REPO_ROOT/tool/demo_server/.venv/bin/python" - else - PY="$REPO_ROOT/tool/demo_server/.venv/Scripts/python" - fi - "$PY" -m pip install -q -r tool/demo_server/requirements.txt -fi - -# ---------------------------------------------------------------- cleanup --- - -STARTED_EMULATOR=0 -EMULATOR_PID="" -cleanup() { - local code=$? - set +e - if [ -n "${SERIAL:-}" ]; then - adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1 - adb -s "$SERIAL" reverse --remove-all >/dev/null 2>&1 - fi - if [ "$STARTED_EMULATOR" = 1 ] && [ "${KEEP_EMULATOR:-0}" != 1 ]; then - adb -s "$SERIAL" emu kill >/dev/null 2>&1 - fi - if [ "${KEEP_SERVER:-0}" != 1 ]; then - "${DC[@]}" down -v >/dev/null 2>&1 - fi - exit "$code" -} -trap cleanup EXIT - -# ---------------------------------------------------------- demo server --- - -log "Starting a fresh demo Nextcloud (port $DEMO_PORT)" -"${DC[@]}" down -v >/dev/null 2>&1 || true -"${DC[@]}" up -d - -log "Seeding fake data" -APP_PASSWORD="$(DEMO_BASE_URL="http://localhost:$DEMO_PORT" "$PY" tool/demo_server/seed.py | grep '^APP_PASSWORD=' | tail -n1 | cut -d= -f2-)" -[ -n "$APP_PASSWORD" ] || die "the seed script did not return an app password" - -# ---------------------------------------------------------------- device --- - -if [ -n "${ANDROID_SERIAL:-}" ]; then - SERIAL="$ANDROID_SERIAL" - log "Using existing device $SERIAL" -else - log "Preparing the emulator" - [ -n "$SDK_ROOT" ] || die "set ANDROID_HOME to your Android SDK" - EMULATOR="$(find_tool emulator || true)" - if [ -z "$EMULATOR" ] || ! [ -d "$SDK_ROOT/system-images/android-${API_LEVEL}/google_apis/x86_64" ]; then - log "Installing the emulator + system image (one-off, a few GB)" - yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true - "$SDKMANAGER" "emulator" "platform-tools" "$SYSTEM_IMAGE" - EMULATOR="$(find_tool emulator)" - fi - - # avdmanager also prints a harmless "Could not load devices from - # .../devices.xml" error for system images that ship no devices.xml, so its - # output is hidden and success is judged by whether the AVD exists. - echo no | "$AVDMANAGER" create avd --force -n "$AVD_NAME" -k "$SYSTEM_IMAGE" -d pixel_6 >/dev/null 2>&1 || true - AVD_DIR="$(to_unix "${ANDROID_AVD_HOME:-$HOME/.android/avd}")/$AVD_NAME.avd" - [ -d "$AVD_DIR" ] || die "could not find the AVD at $AVD_DIR (set ANDROID_AVD_HOME)" - set_ini() { # : replace or append in the AVD's config.ini - if grep -q "^$1=" "$AVD_DIR/config.ini"; then - sed -i.bak "s|^$1=.*|$1=$2|" "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak" - else - echo "$1=$2" >> "$AVD_DIR/config.ini" - fi - } - sed -i.bak '/^skin\.path=/d' "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak" - set_ini skin.name "${SCREEN_W}x${SCREEN_H}" - set_ini hw.lcd.width "$SCREEN_W" - set_ini hw.lcd.height "$SCREEN_H" - set_ini hw.lcd.density "$SCREEN_DENSITY" - - log "Booting the emulator" - EMULATOR_FLAGS=(-avd "$AVD_NAME" -no-snapshot -wipe-data -no-audio -no-boot-anim -gpu swiftshader_indirect) - [ "${HEADLESS:-0}" = 1 ] && EMULATOR_FLAGS+=(-no-window) - "$EMULATOR" "${EMULATOR_FLAGS[@]}" >/tmp/noo_emulator.log 2>&1 & - EMULATOR_PID=$! - STARTED_EMULATOR=1 - - adb wait-for-device - for _ in $(seq 1 150); do - [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break - sleep 2 - done - [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] || die "the emulator did not finish booting (see /tmp/noo_emulator.log)" - SERIAL="$(adb devices | awk '/^emulator-/{print $1; exit}')" -fi -export ANDROID_SERIAL="$SERIAL" - -log "Configuring the device" -adb shell input keyevent 82 >/dev/null 2>&1 || true # dismiss keyguard -adb shell svc power stayon true >/dev/null 2>&1 || true # never sleep mid-run - -# Make the demo server reachable from the device as localhost. Binding a port -# below 1024 (the default 80) on the device needs root - the google_apis -# emulator images allow `adb root` - otherwise fall back to a high device -# port, which then shows up as ":8080" in the app. -DEVICE_PORT="$DEMO_PORT" -if [ "$DEMO_PORT" -lt 1024 ]; then - adb root >/dev/null 2>&1 || true - sleep 2 - adb wait-for-device -fi -if ! adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" >/dev/null 2>&1; then - DEVICE_PORT=8080 - printf 'note: could not bind port %s on the device (no root); using %s instead\n' "$DEMO_PORT" "$DEVICE_PORT" >&2 - adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" || die "adb reverse failed" -fi - -SERVER_HOST="localhost" -if [ -n "$DEMO_HOST" ]; then - # Best effort: make DEMO_HOST resolve to the (reverse-forwarded) loopback. - if adb root >/dev/null 2>&1 && sleep 2 && adb remount >/dev/null 2>&1 && - adb shell "echo '127.0.0.1 $DEMO_HOST' >> /system/etc/hosts" >/dev/null 2>&1; then - SERVER_HOST="$DEMO_HOST" - else - printf 'warning: could not edit the device hosts file; showing "localhost" instead of %s\n' "$DEMO_HOST" >&2 - fi -fi -SERVER_URL="http://$SERVER_HOST" -[ "$DEVICE_PORT" = 80 ] || SERVER_URL="$SERVER_URL:$DEVICE_PORT" - -# Clean status bar: 12:00, full battery, full Wi-Fi, no notification icons. -adb shell settings put global sysui_demo_allowed 1 -demo() { adb shell am broadcast -a com.android.systemui.demo -e command "$@" >/dev/null; } -demo enter -demo clock -e hhmm 1200 -demo battery -e level 100 -e plugged false -demo network -e wifi show -e level 4 -e fully true -e mobile hide -demo notifications -e visible false - -# --------------------------------------------------------------- capture --- - -log "Capturing screenshots (this drives the app for a few minutes)" -rm -rf "$OUT_DIR" -mkdir -p "$OUT_DIR" -SCREENSHOT_DIR="$OUT_DIR" flutter drive \ - --driver=test_driver/integration_test.dart \ - --target=integration_test/store_screenshots_test.dart \ - -d "$SERIAL" \ - --dart-define="DEMO_SERVER_URL=$SERVER_URL" \ - --dart-define="DEMO_USERNAME=Alex" \ - --dart-define="DEMO_APP_PASSWORD=$APP_PASSWORD" - -log "Making them Play Store ready" -"$PY" tool/finalize_screenshots.py "$OUT_DIR" - -log "Done: $OUT_DIR" -printf '\nBefore uploading, LOOK at every image for anything real: the server\n' >&2 -printf 'address/username in the header and Settings, notification icons, names.\n' >&2