Fix release signing and unify "+" upload with the share-intent path
Build APK / build (push) Successful in 5m21s
Build APK / build (push) Successful in 5m21s
- Release builds now sign with a dedicated keystore (local android/key.properties or CI secrets, both gitignored) instead of each machine's own debug key, so a downloaded release APK can actually update a previous install instead of failing with "App not installed" (mismatched signature). - The Files tab's "+" -> "Upload File" now goes through the same ShareUploadView destination picker and background foreground-service upload as receiving a file via Android's "Share to..." sheet, instead of a separate blocking in-app-only upload path. Removed the now-unused ServerProvider/NextcloudService.uploadFileFromPath. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
hand-rolled native handling in `MainActivity.kt` - see `server.md` for why
|
||||||
this isn't the `receive_sharing_intent` plugin): both `getInitialShare()`
|
this isn't the `receive_sharing_intent` plugin): both `getInitialShare()`
|
||||||
(cold start via another app's "Share to...") and `onNewShare` (already
|
(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
|
(`PickIntentService` - see `server.md` for the full "being picked by
|
||||||
another app" story) that feeds `ServerProvider.pickRequest`; while
|
another app" story) that feeds `ServerProvider.pickRequest`; while
|
||||||
`isPicking`, the visible tab list is overridden to just Files and Photos
|
`isPicking`, the visible tab list is overridden to just Files and Photos
|
||||||
|
|||||||
@@ -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
|
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
|
on the device. `android/app/build.gradle.kts` picks a release signing key
|
||||||
signing config (`android/app/build.gradle.kts`), so consecutive local
|
in this order: a local `android/key.properties` (gitignored — points at a
|
||||||
builds share a key and `adb install -r` works cleanly. Installing a build
|
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
|
||||||
signed with a different key (e.g. a CI-signed release APK from the Gitea
|
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
|
||||||
release pipeline) over a differently-signed local build forces Android to
|
`_PASSWORD`, set by `.gitea/workflows/build.yml` from repo secrets), then
|
||||||
require a full uninstall regardless of the install method used — there's no
|
falls back to the debug key if neither is configured. As long as the same
|
||||||
way around that from the tooling side.
|
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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,22 @@ jobs:
|
|||||||
- name: Analyze
|
- name: Analyze
|
||||||
run: flutter 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
|
- name: Build release APK
|
||||||
run: flutter build apk --release
|
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-<version>.apk
|
- name: Rename APK to Noo-<version>.apk
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ GeneratedPluginRegistrant.java
|
|||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
RELEASE_SIGNING_SECRETS.txt
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "dev.ayushya.noo"
|
namespace = "dev.ayushya.noo"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -27,11 +53,27 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(releaseStoreFile!!)
|
||||||
|
storePassword = releaseStorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Falls back to the debug keystore (so an unconfigured
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// checkout can still `flutter run --release`) when no release
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// signing config is available - see the comment above.
|
||||||
|
signingConfig = if (hasReleaseSigning) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1574,24 +1574,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> 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<bool> deleteItem(String itemPath) async {
|
Future<bool> deleteItem(String itemPath) async {
|
||||||
if (_service == null) return false;
|
if (_service == null) return false;
|
||||||
final success = await _service!.deleteItem(itemPath);
|
final success = await _service!.deleteItem(itemPath);
|
||||||
|
|||||||
@@ -1103,44 +1103,6 @@ class NextcloudService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uploads a file from disk, streaming it so large files don't need to be
|
|
||||||
/// buffered fully in memory, with progress reporting.
|
|
||||||
Future<bool> 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<void>(
|
|
||||||
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<bool> deleteItem(String itemPath) async {
|
Future<bool> deleteItem(String itemPath) async {
|
||||||
var cleanPath = itemPath.trim();
|
var cleanPath = itemPath.trim();
|
||||||
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
||||||
|
|||||||
+23
-52
@@ -9,6 +9,7 @@ import '../models/nextcloud_item.dart';
|
|||||||
import '../models/selection_action.dart';
|
import '../models/selection_action.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
|
import '../services/share_intent_service.dart';
|
||||||
import '../widgets/breadcrumbs.dart';
|
import '../widgets/breadcrumbs.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
import '../widgets/item_icon.dart';
|
import '../widgets/item_icon.dart';
|
||||||
@@ -23,6 +24,7 @@ import '../widgets/swipeable_item.dart';
|
|||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/synced_header_scaffold.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
|
import 'share_upload_view.dart';
|
||||||
|
|
||||||
class FilesView extends StatefulWidget {
|
class FilesView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
@@ -1207,7 +1209,7 @@ class _FilesViewState extends State<FilesView>
|
|||||||
title: const Text('Upload File'),
|
title: const Text('Upload File'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_pickAndUploadFile(context, provider);
|
_pickAndUploadFile(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1274,58 +1276,27 @@ class _FilesViewState extends State<FilesView>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickAndUploadFile(
|
// Mirrors `main.dart`'s `_handleSharedFiles` exactly, so picking a file
|
||||||
BuildContext context,
|
// via "+" lands on the same destination-picker screen and background
|
||||||
ServerProvider provider,
|
// foreground-service upload as receiving one via Android's "Share
|
||||||
) async {
|
// to..." sheet does, rather than a separate in-app-only upload path.
|
||||||
final result = await FilePicker.pickFiles();
|
Future<void> _pickAndUploadFile(BuildContext context) async {
|
||||||
if (result.isEmpty) return;
|
final picked = await FilePicker.pickFiles();
|
||||||
final picked = result.first;
|
if (picked.isEmpty) return;
|
||||||
if (picked.path == null) return;
|
final files = picked
|
||||||
|
.where((f) => f.path != null)
|
||||||
final progress = ValueNotifier<double?>(0);
|
.map(
|
||||||
if (!context.mounted) return;
|
(f) => SharedFileRef(
|
||||||
|
uri: Uri.file(f.path!).toString(),
|
||||||
showDialog(
|
name: f.name,
|
||||||
context: context,
|
size: f.lengthSync(),
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text('Uploading ${picked.name}'),
|
|
||||||
content: ValueListenableBuilder<double?>(
|
|
||||||
valueListenable: progress,
|
|
||||||
builder: (context, value, _) =>
|
|
||||||
LinearProgressIndicator(value: value),
|
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
},
|
.toList();
|
||||||
);
|
if (files.isEmpty || !context.mounted) return;
|
||||||
|
|
||||||
bool success = false;
|
Navigator.of(
|
||||||
try {
|
context,
|
||||||
success = await provider.uploadFileFromPath(
|
).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files)));
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user