diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b5fd30a..ce80bef 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + + + to be uploaded - handled by MainActivity.kt directly, not a + plugin (see its doc comment for why). --> @@ -43,6 +50,10 @@ + + when (call.method) { + "getInitialShare" -> result.success(extractShareMetadata(intent)) + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, newShareChannelName) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + newShareSink = events + } + override fun onCancel(arguments: Any?) { + newShareSink = null + } + }) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, uploadServiceChannelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "startUpload" -> { + startUploadService(call, result) + } + else -> result.notImplemented() + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + val shared = extractShareMetadata(intent) + if (shared.isNotEmpty()) { + mainHandler.post { newShareSink?.success(shared) } + } + } + + /// Cheap Uri metadata only (display name / size / mime type) via a + /// single ContentResolver query - never reads the file's actual bytes, + /// so this is fast regardless of how large the shared file is. + private fun extractShareMetadata(intent: Intent): List> { + val uris: List = when (intent.action) { + Intent.ACTION_SEND -> listOfNotNull(intentStreamUri(intent)) + Intent.ACTION_SEND_MULTIPLE -> intentStreamUris(intent) + else -> emptyList() + } + return uris.map { uriMetadata(it) } + } + + @Suppress("DEPRECATION") + private fun intentStreamUri(intent: Intent): Uri? { + return if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + } + + @Suppress("DEPRECATION") + private fun intentStreamUris(intent: Intent): List { + val list = if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + return list ?: emptyList() + } + + private fun uriMetadata(uri: Uri): Map { + var name = uri.lastPathSegment ?: "shared_file" + var size: Long? = null + try { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIdx >= 0) cursor.getString(nameIdx)?.let { name = it } + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) + } + } + } catch (_: Exception) { + // Fall back to the Uri-derived name / unknown size below. + } + val mimeType = contentResolver.getType(uri) + return mapOf("uri" to uri.toString(), "name" to name, "mimeType" to mimeType, "size" to size) + } + + /// Starts ShareUploadService with everything it needs to run entirely on + /// its own (see its doc comment) - the Intent extras are the full + /// contract between this call and that service, kept in sync manually + /// since they cross the Kotlin/Dart boundary as loosely-typed args. + private fun startUploadService(call: MethodCall, result: MethodChannel.Result) { + val filesJson = call.argument("files") + val serverUrl = call.argument("serverUrl") + val username = call.argument("username") + val authHeader = call.argument("authHeader") + val remoteFolder = call.argument("remoteFolder") + if (filesJson == null || serverUrl == null || username == null || + authHeader == null || remoteFolder == null + ) { + result.error("bad_args", "Missing required upload arguments", null) + return + } + + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + // Fire-and-forget: the service works fine even if this is denied, + // it just won't be able to show progress/cancel in a notification. + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + notificationPermissionRequestCode, + ) + } + + val serviceIntent = Intent(this, ShareUploadService::class.java).apply { + putExtra(ShareUploadService.EXTRA_FILES, filesJson) + putExtra(ShareUploadService.EXTRA_SERVER_URL, serverUrl) + putExtra(ShareUploadService.EXTRA_USERNAME, username) + putExtra(ShareUploadService.EXTRA_AUTH_HEADER, authHeader) + putExtra(ShareUploadService.EXTRA_REMOTE_FOLDER, remoteFolder) + } + ContextCompat.startForegroundService(this, serviceIntent) + result.success(null) + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt new file mode 100644 index 0000000..f9cc191 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt @@ -0,0 +1,314 @@ +package dev.ayushya.noo + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import org.json.JSONArray +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Foreground service that prepares (copies from a content:// Uri) and + * uploads one or more "Share to Noo" files over WebDAV, independent of + * MainActivity/the Flutter engine being alive - the whole point is that + * closing the app right after confirming a destination in ShareUploadView + * doesn't interrupt the upload, the same way a file-manager app's own + * upload notification survives the app being closed. + * + * Re-implements a plain WebDAV PUT here in Kotlin (HttpURLConnection, no + * new HTTP dependency) rather than reusing NextcloudService/Dio from Dart, + * since a Dart isolate doesn't keep running once the Flutter engine/ + * Activity are gone - only a real Android Service does. This does mean the + * PUT request itself is duplicated logic (see NextcloudService. + * uploadFileFromPath); keep both in sync if the upload semantics change. + * + * Started via the `dev.ayushya.noo/upload_service` MethodChannel + * (MainActivity.kt) with credentials/destination passed as Intent extras - + * never has an Activity in the loop after that. Shows one persistent, + * cancellable notification for the whole batch; the Cancel action re-enters + * this same running service instance with [ACTION_CANCEL], which the + * copy/upload loops poll. + */ +class ShareUploadService : Service() { + companion object { + const val ACTION_CANCEL = "dev.ayushya.noo.action.CANCEL_UPLOAD" + const val EXTRA_FILES = "files" // JSON array of {uri, name, size} + const val EXTRA_SERVER_URL = "serverUrl" + const val EXTRA_USERNAME = "username" + const val EXTRA_AUTH_HEADER = "authHeader" + const val EXTRA_REMOTE_FOLDER = "remoteFolder" + + private const val CHANNEL_ID = "share_upload" + private const val NOTIFICATION_ID = 4201 + } + + private val cancelled = AtomicBoolean(false) + private var uploadThread: Thread? = null + + private data class ShareFile(val uri: String, val name: String, val size: Long?) + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_CANCEL) { + cancelled.set(true) + return START_NOT_STICKY + } + + val filesJson = intent?.getStringExtra(EXTRA_FILES) + val serverUrl = intent?.getStringExtra(EXTRA_SERVER_URL) + val username = intent?.getStringExtra(EXTRA_USERNAME) + val authHeader = intent?.getStringExtra(EXTRA_AUTH_HEADER) + val remoteFolder = intent?.getStringExtra(EXTRA_REMOTE_FOLDER) + if (filesJson == null || serverUrl == null || username == null || + authHeader == null || remoteFolder == null + ) { + stopSelf() + return START_NOT_STICKY + } + + createNotificationChannel() + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + buildProgressNotification("Preparing to upload…", null, indeterminate = true), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + + // A second share arriving mid-upload is dropped rather than queued - + // rare in practice (sharing again before the first batch finishes), + // not worth a real queue for. + if (uploadThread == null) { + val files = parseFiles(filesJson) + uploadThread = Thread { + runUploads(files, serverUrl, username, authHeader, remoteFolder) + }.also { it.start() } + } + + return START_NOT_STICKY + } + + private fun parseFiles(json: String): List { + val arr = JSONArray(json) + return (0 until arr.length()).map { i -> + val obj = arr.getJSONObject(i) + ShareFile( + uri = obj.getString("uri"), + name = obj.getString("name"), + size = if (obj.isNull("size")) null else obj.getLong("size"), + ) + } + } + + private fun runUploads( + files: List, + serverUrl: String, + username: String, + authHeader: String, + remoteFolder: String, + ) { + var succeeded = 0 + var failed = 0 + val cleanServer = serverUrl.trimEnd('/') + var cleanFolder = remoteFolder.trim() + if (!cleanFolder.startsWith("/")) cleanFolder = "/$cleanFolder" + if (!cleanFolder.endsWith("/")) cleanFolder = "$cleanFolder/" + + for ((index, file) in files.withIndex()) { + if (cancelled.get()) break + val label = if (files.size == 1) file.name else "${file.name} (${index + 1}/${files.size})" + var tempFile: File? = null + try { + notify(buildProgressNotification("Preparing $label…", null, indeterminate = true)) + tempFile = materialize(file) { sent, total -> + notify( + buildProgressNotification( + "Preparing $label…", + progressFraction(sent, total), + indeterminate = total == null, + ), + ) + } + if (cancelled.get()) break + + val encodedName = Uri.encode(file.name) + val url = URL("$cleanServer/remote.php/dav/files/$username$cleanFolder$encodedName") + val ok = uploadFile(tempFile, url, authHeader) { sent, total -> + notify( + buildProgressNotification( + "Uploading $label…", + progressFraction(sent, total), + indeterminate = false, + ), + ) + } + if (ok) succeeded++ else failed++ + } catch (e: Exception) { + failed++ + } finally { + tempFile?.delete() + } + } + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val finalText = when { + cancelled.get() -> "Upload cancelled" + failed == 0 && files.size == 1 -> "Uploaded ${files.first().name}" + failed == 0 -> "Uploaded $succeeded of ${files.size} files" + else -> "Uploaded $succeeded of ${files.size} files - $failed failed" + } + manager.notify(NOTIFICATION_ID, buildFinalNotification(finalText)) + + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } + + private fun progressFraction(sent: Long, total: Long?): Float? { + if (total == null || total <= 0) return null + return (sent.toFloat() / total.toFloat()).coerceIn(0f, 1f) + } + + private fun materialize(file: ShareFile, onProgress: (Long, Long?) -> Unit): File { + val uri = Uri.parse(file.uri) + val target = File(cacheDir, "share_upload_${System.currentTimeMillis()}_${file.name}") + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(target).use { output -> + val buffer = ByteArray(256 * 1024) + var sent = 0L + var lastEmit = 0L + while (!cancelled.get()) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + sent += read + val now = System.currentTimeMillis() + if (now - lastEmit >= 200) { + lastEmit = now + onProgress(sent, file.size) + } + } + } + } ?: throw IllegalStateException("Could not open ${file.uri}") + return target + } + + private fun uploadFile( + file: File, + url: URL, + authHeader: String, + onProgress: (Long, Long) -> Unit, + ): Boolean { + val length = file.length() + val connection = url.openConnection() as HttpURLConnection + return try { + connection.requestMethod = "PUT" + connection.doOutput = true + connection.setFixedLengthStreamingMode(length) + connection.setRequestProperty("Authorization", authHeader) + connection.setRequestProperty("OCS-APIRequest", "true") + connection.setRequestProperty("Content-Type", "application/octet-stream") + connection.connectTimeout = 15000 + connection.readTimeout = 30000 + + connection.outputStream.use { output -> + file.inputStream().use { input -> + val buffer = ByteArray(256 * 1024) + var sent = 0L + var lastEmit = 0L + while (!cancelled.get()) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + sent += read + val now = System.currentTimeMillis() + if (now - lastEmit >= 200) { + lastEmit = now + onProgress(sent, length) + } + } + } + } + if (cancelled.get()) { + false + } else { + val code = connection.responseCode + code == 201 || code == 204 || code == 200 + } + } catch (e: Exception) { + false + } finally { + connection.disconnect() + } + } + + private fun notify(notification: Notification) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "File uploads", + NotificationManager.IMPORTANCE_LOW, + ).apply { description = "Progress for files shared to Noo" } + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + private fun buildProgressNotification(text: String, progress: Float?, indeterminate: Boolean): Notification { + val cancelIntent = Intent(this, ShareUploadService::class.java).apply { action = ACTION_CANCEL } + val cancelPendingIntent = PendingIntent.getService( + this, + 0, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle("Uploading to Noo") + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent) + when { + indeterminate -> builder.setProgress(100, 0, true) + progress != null -> builder.setProgress(100, (progress * 100).toInt(), false) + } + return builder.build() + } + + private fun buildFinalNotification(text: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload_done) + .setContentTitle("Noo") + .setContentText(text) + .setOngoing(false) + .setAutoCancel(true) + .build() + } + + override fun onDestroy() { + cancelled.set(true) + super.onDestroy() + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index d95ee72..dbee657 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -19,21 +19,6 @@ subprojects { project.evaluationDependsOn(":app") } -// receive_sharing_intent declares Java 11 for its Java sources but leaves -// its Kotlin compilation to default to whatever JDK runs the build - with -// JDK 17 that mismatch (Java 11 vs Kotlin 17) fails with "Inconsistent JVM -// Target Compatibility". Pin just this plugin's Kotlin target down to match -// its own declared Java target, rather than touching every subproject. -project(":receive_sharing_intent") { - afterEvaluate { - tasks.withType().configureEach { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) - } - } - } -} - tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/lib/main.dart b/lib/main.dart index bc75bdb..695116f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'models/app_tab.dart'; import 'providers/server_provider.dart'; +import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; import 'views/lock_screen_view.dart'; import 'views/login_view.dart'; @@ -112,7 +112,7 @@ class MainShellView extends StatefulWidget { class _MainShellViewState extends State { late AppTab _currentTab; late final Map _scrollControllers; - StreamSubscription>? _shareSub; + StreamSubscription>? _shareSub; @override void initState() { @@ -126,29 +126,22 @@ class _MainShellViewState extends State { _currentTab = context.read().defaultTab; // Handles both a cold start via another app's "Share to..." sheet - // (getInitialMedia) and a share arriving while the app is already - // running (getMediaStream) - the plugin guarantees the stream doesn't - // re-emit whatever getInitialMedia already returned. - ReceiveSharingIntent.instance.getInitialMedia().then(_handleSharedFiles); - _shareSub = ReceiveSharingIntent.instance.getMediaStream().listen( - _handleSharedFiles, - ); + // (getInitialShare) and a share arriving while the app is already + // running (onNewShare). See MainActivity.kt's doc comment for why this + // is hand-rolled instead of the receive_sharing_intent plugin - in + // short, this only ever fetches cheap Uri metadata here, never a + // file's actual bytes, so a large shared file can't block startup. + ShareIntentService.getInitialShare().then(_handleSharedFiles); + _shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles); } - void _handleSharedFiles(List files) { - final uploadable = files - .where( - (f) => - f.type != SharedMediaType.text && f.type != SharedMediaType.url, - ) - .toList(); - if (uploadable.isEmpty) return; - ReceiveSharingIntent.instance.reset(); + void _handleSharedFiles(List files) { + if (files.isEmpty) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ShareUploadView(files: uploadable)), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files))); }); } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 9fc2f20..c565dcc 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -725,20 +725,39 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { final init = await LoginFlowService.initiate(serverUrl); _pendingLoginUrl = init.loginUrl; - // Chrome Custom Tabs (inAppBrowserView): real Chrome, so the saved - // passwords/autofill service works normally, unlike Flutter's own - // embedded web view. The tradeoff is that - like Login Flow v2's - // grant page itself, which never redirects back into the app on its - // own (unlike the old nc:// Flow v1) - a Custom Tab belongs to - // Chrome's own task, not ours, so we have no way to close it - // automatically once polling below detects success; the user has to - // switch back manually, same as with the external browser. - final opened = await launchUrl( - init.loginUrl, - mode: LaunchMode.inAppBrowserView, - ); - if (!opened) { - throw Exception('Could not open the browser for login.'); + // Chrome Custom Tabs (inAppBrowserView) for the first/only login: + // real Chrome, so the saved passwords/autofill service works + // normally, unlike Flutter's own embedded web view. The tradeoff is + // that - like Login Flow v2's grant page itself, which never + // redirects back into the app on its own (unlike the old nc:// + // Flow v1) - a Custom Tab belongs to Chrome's own task, not ours, so + // we have no way to close it automatically once polling below + // detects success; the user has to switch back manually, same as + // with the external browser. + // + // Adding another account instead shows the login page in + // LoginWebViewView, a real screen this app owns (pushed by LoginView + // once loginFlowStatus flips to awaitingBrowser below), backed by + // Flutter's own WebView rather than a Custom Tab. Two reasons, not + // just one: (1) a Custom Tab shares Chrome's actual browser + // profile/cookie jar, so if the user is still logged into the first + // account on the Nextcloud web UI in Chrome, it would silently reuse + // that session and authorize the wrong account instead of prompting + // fresh credentials; (2) url_launcher's own LaunchMode.inAppWebView + // is a bare native WebView Activity with no chrome of its own - no + // close button, and on at least some devices it draws edge-to-edge + // and hides the status bar. Owning the screen ourselves fixes both: + // isolated cookies, plus a normal AppBar/close button/safe area, and + // as a bonus we CAN close it automatically on success (LoginWebView + // View watches loginFlowStatus itself), unlike the Custom Tab case. + if (!addAccount) { + final opened = await launchUrl( + init.loginUrl, + mode: LaunchMode.inAppBrowserView, + ); + if (!opened) { + throw Exception('Could not open the browser for login.'); + } } _loginFlowStatus = LoginFlowStatus.awaitingBrowser; @@ -790,6 +809,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Only relevant to the first/only-login Chrome Custom Tab path - the + // add-account LoginWebViewView is a normal pushed screen the user can't + // lose track of, so it has no equivalent "reopen" need. Future reopenLoginBrowser() async { if (_pendingLoginUrl != null) { await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView); diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart new file mode 100644 index 0000000..85ec749 --- /dev/null +++ b/lib/services/share_intent_service.dart @@ -0,0 +1,61 @@ +import 'package:flutter/services.dart'; + +/// A file shared to Noo from another app's "Share to..." sheet, before its +/// bytes have been touched - just the `content://` Uri and whatever cheap +/// metadata Android will hand over without reading the file itself. +class SharedFileRef { + final String uri; + final String name; + final String? mimeType; + final int? size; + + const SharedFileRef({ + required this.uri, + required this.name, + this.mimeType, + this.size, + }); + + factory SharedFileRef.fromMap(Map map) { + return SharedFileRef( + uri: map['uri'] as String, + name: map['name'] as String? ?? 'shared_file', + mimeType: map['mimeType'] as String?, + size: (map['size'] as num?)?.toInt(), + ); + } +} + +/// Talks to `MainActivity.kt`'s hand-rolled share-intent handling (see its +/// doc comment for why this isn't the receive_sharing_intent plugin): it +/// only ever exposes cheap Uri metadata up front. Actually reading a shared +/// file's bytes - preparing and uploading it - is [UploadService]'s job +/// (`ShareUploadService.kt`'s Android foreground service), not this one. +class ShareIntentService { + static const _methodChannel = MethodChannel('dev.ayushya.noo/share_intent'); + static const _newShareChannel = EventChannel( + 'dev.ayushya.noo/share_intent/new', + ); + + /// Whatever was shared to launch the app cold (empty if it was launched + /// normally, not via a share). + static Future> getInitialShare() async { + final result = await _methodChannel.invokeMethod>( + 'getInitialShare', + ); + return (result ?? []) + .cast>() + .map(SharedFileRef.fromMap) + .toList(); + } + + /// Emits whenever another share arrives while the app is already running. + static Stream> get onNewShare { + return _newShareChannel.receiveBroadcastStream().map((event) { + return (event as List) + .cast>() + .map(SharedFileRef.fromMap) + .toList(); + }); + } +} diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart new file mode 100644 index 0000000..07b4cd7 --- /dev/null +++ b/lib/services/upload_service.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import '../providers/server_provider.dart'; +import 'share_intent_service.dart'; + +/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android +/// foreground service, which does the actual prepare+upload entirely on its +/// own from there - including surviving the app being closed - with a +/// single cancellable progress notification for the whole batch. See that +/// service's doc comment for why this isn't done in Dart/Dio: a Dart +/// isolate doesn't keep running once the Flutter engine is gone, only a +/// real Android Service does. +class UploadService { + static const _channel = MethodChannel('dev.ayushya.noo/upload_service'); + + /// Starts uploading [files] into the active account's current folder. + /// Fire-and-forget from Dart's perspective - once this returns, the + /// service owns the rest and reports progress/completion/cancellation + /// through its own notification, not back to the app. + static Future startUpload( + ServerProvider provider, + List files, + ) async { + final service = provider.service; + final authHeader = service?.authHeaders['Authorization']; + if (service == null || authHeader == null) { + throw Exception('Not logged in.'); + } + + final filesJson = jsonEncode( + files.map((f) => {'uri': f.uri, 'name': f.name, 'size': f.size}).toList(), + ); + + await _channel.invokeMethod('startUpload', { + 'files': filesJson, + 'serverUrl': provider.serverUrl, + 'username': provider.username, + 'authHeader': authHeader, + 'remoteFolder': provider.currentFolderPath, + }); + } +} diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index 0443011..ef65895 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/saved_account.dart'; import '../providers/server_provider.dart'; +import 'login_webview_view.dart'; class LoginView extends StatefulWidget { /// True when this is pushed from Settings ("Add account") on top of an @@ -21,6 +22,7 @@ class _LoginViewState extends State { final _urlController = TextEditingController(); String? _originalActiveAccountId; bool _popped = false; + bool _webViewPushed = false; @override void initState() { @@ -67,6 +69,27 @@ class _LoginViewState extends State { }); } + // The add-account flow shows its login page in LoginWebViewView (a + // real pushed screen, not a Custom Tab - see ServerProvider. + // startLoginFlow's doc comment for why) rather than this view's own + // _WaitingForBrowser, so push it the moment there's a URL to show. + // _webViewPushed resets once that route pops (cancelled or done) so a + // retry after cancelling pushes it again. + if (widget.isAddingAccount && + !_webViewPushed && + isAwaitingBrowser && + provider.pendingLoginUrl != null) { + _webViewPushed = true; + final url = provider.pendingLoginUrl!; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => LoginWebViewView(url: url))); + _webViewPushed = false; + }); + } + final body = Scaffold( appBar: widget.isAddingAccount ? AppBar(title: const Text('Add Account')) @@ -78,7 +101,7 @@ class _LoginViewState extends State { padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), - child: isAwaitingBrowser + child: isAwaitingBrowser && !widget.isAddingAccount ? _WaitingForBrowser( onCancel: () => provider.cancelLoginFlow(), onReopenBrowser: provider.reopenLoginBrowser, @@ -120,7 +143,10 @@ class _LoginViewState extends State { _ServerForm( formKey: _formKey, urlController: _urlController, - isLoading: isInitiating || provider.isLoading, + isLoading: + isInitiating || + provider.isLoading || + (widget.isAddingAccount && isAwaitingBrowser), errorMessage: provider.loginFlowStatus == LoginFlowStatus.error ? provider.errorMessage @@ -182,10 +208,18 @@ class _ServerForm extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(20), + // Same monochrome-tinted treatment as the splash/lock screens + // (see main.dart's _SplashView) - the asset is a plain white + // silhouette on transparent, meant to be recolored rather than + // shown as-is. Used everywhere the app shows its own icon + // in-app, rather than the full-color launcher icon. + child: ColorFiltered( + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurface, + BlendMode.srcIn, + ), child: Image.asset( - 'assets/icon/app_icon.png', + 'assets/icon/app_icon_monochrome.png', width: 80, height: 80, ), diff --git a/lib/views/login_webview_view.dart b/lib/views/login_webview_view.dart new file mode 100644 index 0000000..c7d40f3 --- /dev/null +++ b/lib/views/login_webview_view.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import '../providers/server_provider.dart'; + +/// The "add another account" Login Flow v2 page, rendered in Flutter's own +/// WebView rather than url_launcher's `LaunchMode.inAppWebView` - that mode +/// hosts a bare native WebView Activity with no chrome of its own: no close +/// button, and on at least some devices it draws edge-to-edge and hides the +/// status bar with no way back short of the OS back gesture. A normal +/// Scaffold/AppBar here gets the status bar/safe-area handling and a close +/// button for free, exactly like every other screen in the app. +/// +/// See ServerProvider.startLoginFlow's doc comment for why the add-account +/// flow uses an embedded WebView (isolated cookies) instead of a Chrome +/// Custom Tab at all. +class LoginWebViewView extends StatefulWidget { + final Uri url; + + const LoginWebViewView({super.key, required this.url}); + + @override + State createState() => _LoginWebViewViewState(); +} + +class _LoginWebViewViewState extends State { + late final WebViewController _controller; + bool _pageLoading = true; + bool _popped = false; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) { + if (mounted) setState(() => _pageLoading = true); + }, + onPageFinished: (_) { + if (mounted) setState(() => _pageLoading = false); + }, + ), + ) + ..loadRequest(widget.url); + } + + void _cancel() { + context.read().cancelLoginFlow(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + // The login flow finished (success or error) or was cancelled + // elsewhere - close this screen automatically rather than leaving it + // sitting open over a flow that's no longer awaiting the browser. + if (!_popped && + provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser) { + _popped = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).pop(); + }); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _cancel(); + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Add Account'), + leading: IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: 'Cancel', + onPressed: _cancel, + ), + ), + body: Stack( + children: [ + WebViewWidget(controller: _controller), + if (_pageLoading) const LinearProgressIndicator(), + ], + ), + ), + ); + } +} diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 1df4faf..59bdff9 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -1,17 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; +import 'package:intl/intl.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import '../models/app_tab.dart'; +import '../models/nextcloud_item.dart'; import '../providers/server_provider.dart'; +import '../services/share_intent_service.dart'; +import '../services/upload_service.dart'; import '../widgets/breadcrumbs.dart'; +import '../widgets/item_icon.dart'; +import '../widgets/segmented_icon_toggle.dart'; +import '../widgets/sort_menu_button.dart'; +import '../widgets/synced_header_scaffold.dart' show formatBytes; /// Shown when another app shares one or more files to Noo (Android's -/// "Share to..." sheet). Lets the user browse to a destination folder, then -/// uploads every shared file into it via the same WebDAV upload path as the -/// Files tab's own "Upload File" action. +/// "Share to..." sheet). Lets the user browse to a destination folder, +/// mirroring the Files tab's own controls/filters/listing so this feels +/// like the same browser rather than a stripped-down picker, then hands the +/// actual prepare+upload off to [UploadService] - a real Android foreground +/// service (see `ShareUploadService.kt`'s doc comment), not something this +/// screen or even the app needs to stay open for. [files] only ever carry +/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that +/// service is the only thing that ever reads their actual bytes. class ShareUploadView extends StatefulWidget { - final List files; + final List files; const ShareUploadView({super.key, required this.files}); @@ -20,10 +32,6 @@ class ShareUploadView extends StatefulWidget { } class _ShareUploadViewState extends State { - bool _uploading = false; - int _currentFileIndex = 0; - double? _currentFileProgress; - @override void initState() { super.initState(); @@ -35,46 +43,283 @@ class _ShareUploadViewState extends State { } Future _uploadHere(ServerProvider provider) async { - setState(() => _uploading = true); - - var anyFailed = false; - for (var i = 0; i < widget.files.length; i++) { - final file = widget.files[i]; - setState(() { - _currentFileIndex = i; - _currentFileProgress = 0; - }); - final name = p.basename(file.path); - final success = await provider.uploadFileFromPath( - name, - file.path, - onProgress: (sent, total) { - if (total > 0 && mounted) { - setState(() => _currentFileProgress = sent / total); - } - }, + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + await UploadService.startUpload(provider, widget.files); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Could not start upload: $e'), + behavior: SnackBarBehavior.floating, + ), ); - if (!success) anyFailed = true; + return; } - if (!mounted) return; - provider.requestTab(AppTab.files); - Navigator.of(context).popUntil((route) => route.isFirst); - ScaffoldMessenger.of(context).showSnackBar( + navigator.popUntil((route) => route.isFirst); + messenger.showSnackBar( SnackBar( content: Text( - anyFailed - ? 'Some files failed to upload' - : widget.files.length == 1 - ? 'Uploaded ${p.basename(widget.files.first.path)}' - : 'Uploaded ${widget.files.length} files', + widget.files.length == 1 + ? 'Uploading ${widget.files.first.name} - see the notification for progress' + : 'Uploading ${widget.files.length} files - see the notification for progress', ), behavior: SnackBarBehavior.floating, ), ); } + Widget _buildControlsRow(ServerProvider provider) { + return SizedBox( + height: 44, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + IconButton( + icon: Icon( + provider.filesSortAscending + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 20, + ), + visualDensity: VisualDensity.compact, + tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending', + onPressed: provider.toggleFilesSortOrder, + ), + SizedBox( + width: 130, + child: SortMenuButton( + field: provider.filesSortField, + onChanged: provider.setFilesSortField, + ), + ), + ToggleIconButton( + icon: provider.showFavoritesOnlyFiles + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + isSelected: provider.showFavoritesOnlyFiles, + onTap: provider.toggleFavoritesFilterFiles, + tooltip: 'Favorites only', + ), + const SizedBox(width: 4), + ToggleIconButton( + icon: provider.showHiddenFiles + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + isSelected: provider.showHiddenFiles, + onTap: () => provider.toggleShowHiddenFiles(), + tooltip: 'Show hidden files', + ), + const SizedBox(width: 4), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Symbols.circles_rounded, + isSelected: provider.storageScope == StorageScope.cloud, + onTap: () => provider.setStorageScope(StorageScope.cloud), + tooltip: 'Cloud storage', + ), + ToggleIconButton( + icon: Symbols.hard_drive_rounded, + isSelected: provider.storageScope == StorageScope.external, + onTap: () => provider.setStorageScope(StorageScope.external), + tooltip: 'External storage', + ), + ], + ), + const SizedBox(width: 8), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.select_all_rounded, + isSelected: provider.filesTypeFilter == FilesTypeFilter.all, + onTap: () => provider.setFilesTypeFilter(FilesTypeFilter.all), + tooltip: 'Files & folders', + ), + ToggleIconButton( + icon: Icons.insert_drive_file_outlined, + isSelected: + provider.filesTypeFilter == FilesTypeFilter.filesOnly, + onTap: () => + provider.setFilesTypeFilter(FilesTypeFilter.filesOnly), + tooltip: 'Files only', + ), + ToggleIconButton( + icon: Icons.folder_outlined, + isSelected: + provider.filesTypeFilter == FilesTypeFilter.foldersOnly, + onTap: () => + provider.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + tooltip: 'Folders only', + ), + ], + ), + const SizedBox(width: 8), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.view_list_rounded, + isSelected: !provider.isGridView, + onTap: () => provider.setGridView(false), + tooltip: 'List view', + ), + ToggleIconButton( + icon: Icons.grid_view_rounded, + isSelected: provider.isGridView, + onTap: () => provider.setGridView(true), + tooltip: 'Grid view', + ), + ], + ), + ], + ), + ), + ); + } + + // Only folders are valid upload destinations - files still show (so the + // listing matches what the Files tab itself would show for this folder) + // but are visually dimmed and inert rather than hidden outright. + Widget _buildListTile( + BuildContext context, + NextcloudItem item, + ServerProvider provider, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isFolder = item.isFolder; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Opacity( + opacity: isFolder ? 1 : 0.5, + child: Material( + color: colorScheme.surfaceContainerLow, + child: InkWell( + onTap: isFolder + ? () => provider.navigateToFolder(item.path) + : null, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + children: [ + ItemThumbnail( + item: item, + provider: provider, + size: 44, + borderRadius: 12, + iconSize: 22, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + isFolder + ? 'Folder' + : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (isFolder) const Icon(Icons.chevron_right_rounded), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildGridCard( + BuildContext context, + NextcloudItem item, + ServerProvider provider, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isFolder = item.isFolder; + final iconColor = getIconColor(context, item.type); + + return Opacity( + opacity: isFolder ? 1 : 0.5, + child: Material( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: isFolder ? () => provider.navigateToFolder(item.path) : null, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + getItemIcon(item.type), + color: iconColor, + size: 24, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const SizedBox(height: 2), + Text( + isFolder ? 'Folder' : formatBytes(item.size), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -85,94 +330,79 @@ class _ShareUploadViewState extends State { final currentLabel = currentPath == '/' ? 'Home' : currentPath.split('/').where((s) => s.isNotEmpty).last; - final folders = provider.items.where((i) => i.isFolder).toList(); + final items = provider.items; return Scaffold( appBar: AppBar( title: Text( widget.files.length == 1 - ? 'Upload ${p.basename(widget.files.first.path)}' + ? 'Upload ${widget.files.first.name}' : 'Upload ${widget.files.length} files', ), ), - body: _uploading - ? Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator( - value: _currentFileProgress, - color: colorScheme.primary, - ), - const SizedBox(height: 20), - Text( - widget.files.length == 1 - ? 'Uploading ${p.basename(widget.files[_currentFileIndex].path)}…' - : 'Uploading ${_currentFileIndex + 1} of ${widget.files.length}…', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: _buildControlsRow(provider), + ), + if (hasBreadcrumbs) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Breadcrumbs( + pathStack: provider.pathStack, + onTap: (index) => provider.navigateToPathIndex(index), ), - ) - : Column( - children: [ - if (hasBreadcrumbs) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Breadcrumbs( - pathStack: provider.pathStack, - onTap: (index) => provider.navigateToPathIndex(index), + ), + Expanded( + child: provider.isLoading + ? const Center(child: CircularProgressIndicator()) + : items.isEmpty + ? Center( + child: Text( + 'Folder is empty', + style: TextStyle(color: colorScheme.onSurfaceVariant), ), - ), - Expanded( - child: provider.isLoading - ? const Center(child: CircularProgressIndicator()) - : folders.isEmpty - ? Center( - child: Text( - 'No subfolders here', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : ListView.builder( - itemCount: folders.length, - itemBuilder: (context, index) { - final folder = folders[index]; - return ListTile( - leading: Icon( - Icons.folder_rounded, - color: colorScheme.primary, - ), - title: Text(folder.name), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => - provider.navigateToFolder(folder.path), - ); - }, + ) + : provider.isGridView + ? GridView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.1, + crossAxisSpacing: 12, + mainAxisSpacing: 12, ), - ), - ], - ), - bottomNavigationBar: _uploading - ? null - : SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: () => _uploadHere(provider), - icon: const Icon(Icons.upload_rounded), - label: Text('Upload to $currentLabel'), - ), - ), - ), + itemCount: items.length, + itemBuilder: (context, index) => + _buildGridCard(context, items[index], provider), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + itemCount: items.length, + itemBuilder: (context, index) => + _buildListTile(context, items[index], provider), + ), + ), + ], + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: () => _uploadHere(provider), + icon: const Icon(Icons.upload_rounded), + label: Text('Upload to $currentLabel'), + ), + ), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index a441a02..382f611 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -768,14 +768,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" - receive_sharing_intent: - dependency: "direct main" - description: - name: receive_sharing_intent - sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 - url: "https://pub.dev" - source: hosted - version: "1.8.1" record_use: dependency: transitive description: @@ -1069,6 +1061,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730 + url: "https://pub.dev" + source: hosted + version: "3.26.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b7a5bc7..90ec112 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,9 +53,9 @@ dependencies: share_plus: ^13.3.0 material_symbols_icons: ^4.2960.0 shared_preferences: ^2.5.5 - receive_sharing_intent: ^1.8.1 marquee: ^2.3.0 local_auth: ^2.3.0 + webview_flutter: ^4.14.1 dev_dependencies: flutter_test: