diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 2dec69a..057f6f0 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -118,7 +118,12 @@ owns the app's share-intent listener (`ShareIntentService`, backed by hand-rolled native handling in `MainActivity.kt` - see `server.md` for why this isn't the `receive_sharing_intent` plugin): both `getInitialShare()` (cold start via another app's "Share to...") and `onNewShare` (already -running) push `ShareUploadView`. It similarly owns the pick-intent listener +running) push `ShareUploadView`. `FilesView`'s own "+" → "Upload File" +(`_pickAndUploadFile`) reaches the exact same `ShareUploadView` screen +through the same `SharedFileRef`-based path (wrapping `file_picker`'s +result `Uri`s instead of a share intent's) rather than a separate +in-app-only upload, so both entry points get the same destination picker +and the same durable background-service upload. It similarly owns the pick-intent listener (`PickIntentService` - see `server.md` for the full "being picked by another app" story) that feeds `ServerProvider.pickRequest`; while `isPicking`, the visible tab list is overridden to just Files and Photos diff --git a/.claude/context/standards.md b/.claude/context/standards.md index 651c2d7..b56a8ce 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -84,13 +84,19 @@ adb install -r build/app/outputs/flutter-apk/app-release.apk ``` This only preserves data if the new APK's signature matches what's already -on the device — a local build's release variant currently reuses the debug -signing config (`android/app/build.gradle.kts`), so consecutive local -builds share a key and `adb install -r` works cleanly. Installing a build -signed with a different key (e.g. a CI-signed release APK from the Gitea -release pipeline) over a differently-signed local build forces Android to -require a full uninstall regardless of the install method used — there's no -way around that from the tooling side. +on the device. `android/app/build.gradle.kts` picks a release signing key +in this order: a local `android/key.properties` (gitignored — points at a +gitignored keystore file, e.g. `android/app/release-keystore.jks`), then +CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/ +`_PASSWORD`, set by `.gitea/workflows/build.yml` from repo secrets), then +falls back to the debug key if neither is configured. As long as the same +dedicated release keystore backs both `key.properties` locally and the +Gitea secrets, local release builds and CI-built release APKs share one +signature, so `adb install -r` works cleanly either way. A local checkout +with no `key.properties` set up falls back to the (per-machine, ungitted) +debug key, which won't match a CI-signed APK — installing one over the +other still forces a full uninstall, since there's no way around Android's +signature check from the tooling side. ## Dependencies diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index d9f22c2..8e7a8e8 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -43,8 +43,22 @@ jobs: - name: Analyze run: flutter analyze + - name: Decode release keystore + # Keeps every release build - regardless of which runner built it - + # signed with the same key, so downloaded updates install cleanly + # over a previous release instead of failing with "App not + # installed" (mismatched signature). See android/app/build.gradle.kts. + run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks" + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + - name: Build release APK run: flutter build apk --release + env: + RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/release-keystore.jks + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - name: Rename APK to Noo-.apk run: | diff --git a/android/.gitignore b/android/.gitignore index 93e964b..064e37b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -13,3 +13,4 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +RELEASE_SIGNING_SECRETS.txt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 99418bb..cee381b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,9 +1,35 @@ +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing: prefer a local android/key.properties (gitignored, for +// a developer machine that has the release keystore), then CI env vars +// (set by .gitea/workflows/build.yml from repo secrets), then null - which +// falls back to debug signing below so an unconfigured checkout still +// builds/runs fine, same as before this existed. +val releaseKeystoreProperties = Properties().apply { + val propsFile = rootProject.file("key.properties") + if (propsFile.exists()) propsFile.inputStream().use { load(it) } +} + +fun releaseSigningValue(propertyKey: String, envKey: String): String? = + releaseKeystoreProperties.getProperty(propertyKey) ?: System.getenv(envKey) + +val releaseStoreFile = releaseSigningValue("storeFile", "RELEASE_KEYSTORE_PATH") +val releaseStorePassword = releaseSigningValue("storePassword", "RELEASE_KEYSTORE_PASSWORD") +val releaseKeyAlias = releaseSigningValue("keyAlias", "RELEASE_KEY_ALIAS") +val releaseKeyPassword = releaseSigningValue("keyPassword", "RELEASE_KEY_PASSWORD") +val hasReleaseSigning = listOf( + releaseStoreFile, + releaseStorePassword, + releaseKeyAlias, + releaseKeyPassword, +).all { !it.isNullOrBlank() } + android { namespace = "dev.ayushya.noo" compileSdk = flutter.compileSdkVersion @@ -27,11 +53,27 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Falls back to the debug keystore (so an unconfigured + // checkout can still `flutter run --release`) when no release + // signing config is available - see the comment above. + signingConfig = if (hasReleaseSigning) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 5e67740..effcdbe 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -1574,24 +1574,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _startCacheRefreshTimerIfNeeded(); } - Future uploadFileFromPath( - String name, - String localFilePath, { - void Function(int sent, int total)? onProgress, - }) async { - if (_service == null) return false; - final success = await _service!.uploadFileFromPath( - _currentFolderPath, - name, - localFilePath, - onProgress: onProgress, - ); - if (success) { - await refreshData(); - } - return success; - } - Future deleteItem(String itemPath) async { if (_service == null) return false; final success = await _service!.deleteItem(itemPath); diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index efd01ea..ed3b0fe 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -1103,44 +1103,6 @@ class NextcloudService { return []; } - /// Uploads a file from disk, streaming it so large files don't need to be - /// buffered fully in memory, with progress reporting. - Future uploadFileFromPath( - String folderPath, - String fileName, - String localFilePath, { - void Function(int sent, int total)? onProgress, - }) async { - var cleanPath = folderPath.trim(); - if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; - if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; - - final url = - '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName'; - debugPrint('[Nextcloud DAV] Streaming upload to $url'); - - final file = File(localFilePath); - final length = await file.length(); - final dio = Dio(); - - final response = await dio.put( - url, - data: file.openRead(), - options: Options( - headers: {..._headers, Headers.contentLengthHeader: length}, - contentType: 'application/octet-stream', - ), - onSendProgress: onProgress, - ); - - debugPrint( - '[Nextcloud DAV] Streaming upload status: ${response.statusCode}', - ); - return response.statusCode == 201 || - response.statusCode == 204 || - response.statusCode == 200; - } - Future deleteItem(String itemPath) async { var cleanPath = itemPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 6b66c06..3de3a9c 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -9,6 +9,7 @@ import '../models/nextcloud_item.dart'; import '../models/selection_action.dart'; import '../providers/server_provider.dart'; import '../services/download_service.dart'; +import '../services/share_intent_service.dart'; import '../widgets/breadcrumbs.dart'; import '../widgets/details/details_sheet.dart'; import '../widgets/item_icon.dart'; @@ -23,6 +24,7 @@ import '../widgets/swipeable_item.dart'; import '../widgets/synced_header_scaffold.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; +import 'share_upload_view.dart'; class FilesView extends StatefulWidget { final ScrollController scrollController; @@ -1207,7 +1209,7 @@ class _FilesViewState extends State title: const Text('Upload File'), onTap: () { Navigator.pop(sheetContext); - _pickAndUploadFile(context, provider); + _pickAndUploadFile(context); }, ), ListTile( @@ -1274,58 +1276,27 @@ class _FilesViewState extends State ); } - Future _pickAndUploadFile( - BuildContext context, - ServerProvider provider, - ) async { - final result = await FilePicker.pickFiles(); - if (result.isEmpty) return; - final picked = result.first; - if (picked.path == null) return; - - final progress = ValueNotifier(0); - if (!context.mounted) return; - - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return AlertDialog( - title: Text('Uploading ${picked.name}'), - content: ValueListenableBuilder( - valueListenable: progress, - builder: (context, value, _) => - LinearProgressIndicator(value: value), + // Mirrors `main.dart`'s `_handleSharedFiles` exactly, so picking a file + // via "+" lands on the same destination-picker screen and background + // foreground-service upload as receiving one via Android's "Share + // to..." sheet does, rather than a separate in-app-only upload path. + Future _pickAndUploadFile(BuildContext context) async { + final picked = await FilePicker.pickFiles(); + if (picked.isEmpty) return; + final files = picked + .where((f) => f.path != null) + .map( + (f) => SharedFileRef( + uri: Uri.file(f.path!).toString(), + name: f.name, + size: f.lengthSync(), ), - ); - }, - ); + ) + .toList(); + if (files.isEmpty || !context.mounted) return; - bool success = false; - try { - success = await provider.uploadFileFromPath( - picked.name, - picked.path!, - onProgress: (sent, total) { - if (total > 0) progress.value = sent / total; - }, - ); - } catch (_) { - success = false; - } - - if (context.mounted) { - Navigator.pop(context); // close progress dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? 'Uploaded ${picked.name} to Nextcloud' - : 'Failed to upload ${picked.name}', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files))); } }