Replace receive_sharing_intent; add background upload service; fix add-account login page

Sharing a large file to Noo used to black-screen and silently kick back
to the home screen: receive_sharing_intent copies the entire shared file
into the cache dir synchronously on the main thread before Flutter even
renders, which Android's watchdog eventually kills. Replaced with
hand-rolled handling in MainActivity.kt that only ever reads cheap Uri
metadata up front (ShareIntentService), so the destination picker always
appears instantly regardless of file size.

The destination picker (ShareUploadView) now mirrors the Files tab's own
controls/filters/listing instead of a bare folder list, and the actual
prepare+upload is handed off to ShareUploadService.kt, a real Android
foreground service with a single cancellable progress notification for
the whole batch - this survives the app being closed right after the
user confirms a destination, the same guarantee a file-manager app's own
upload notification gives.

Also fixes the "add another account" login page: it used to open in
url_launcher's bare inAppWebView, which has no close button and can hide
the status bar. LoginWebViewView is a real screen this app owns instead
(package:webview_flutter), with a normal AppBar/close button/safe area,
and can now auto-close itself on successful login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 23:33:55 -04:00
co-authored by Claude Sonnet 5
parent 453aff983a
commit bcc18a7060
13 changed files with 1164 additions and 181 deletions
+12 -1
View File
@@ -2,6 +2,12 @@
<uses-permission android:name="android.permission.INTERNET"/>
<!-- For local_auth's biometric app-lock (Settings > Security). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<!-- For ShareUploadService, which keeps a "Share to Noo" upload going
(and cancellable from its notification) even if the app is fully
closed - see the service's own doc comment. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="Noo"
android:name="${applicationName}"
@@ -31,7 +37,8 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Lets other apps' share sheets ("Share to...") send a file here
to be uploaded, via receive_sharing_intent. -->
to be uploaded - handled by MainActivity.kt directly, not a
plugin (see its doc comment for why). -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
@@ -43,6 +50,10 @@
<data android:mimeType="*/*"/>
</intent-filter>
</activity>
<service
android:name=".ShareUploadService"
android:exported="false"
android:foregroundServiceType="dataSync"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -1,8 +1,183 @@
package dev.ayushya.noo
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
// FlutterFragmentActivity (not the default FlutterActivity) is required by
// local_auth's Android implementation, which hosts its biometric/device
// credential prompt via a Fragment.
class MainActivity : FlutterFragmentActivity()
//
// Also hand-rolls "Share to Noo" intent handling instead of using the
// receive_sharing_intent plugin: that plugin resolves a shared content:// URI
// by synchronously copying the *entire* file into the app's cache dir on the
// main thread, during activity startup, before Flutter has even drawn its
// first frame - fine for a photo, but for a large video/archive it blocks
// long enough that Android decides this newly-launched activity failed to
// render and kills it (silently back to the home screen - no crash, no
// Dart-side error, since Dart never got a chance to run). Other file-manager
// apps show their destination picker instantly regardless of file size
// because they defer touching the file's actual bytes until the user picks
// a destination - `getInitialShare`/`onNewShare` only ever query cheap Uri
// metadata (name/size/mime, not content). The actual copy+upload then runs
// in ShareUploadService (see its own doc comment), not here, so it survives
// the app being closed right after the user confirms a destination.
class MainActivity : FlutterFragmentActivity() {
private val shareIntentChannelName = "dev.ayushya.noo/share_intent"
private val newShareChannelName = "dev.ayushya.noo/share_intent/new"
private val uploadServiceChannelName = "dev.ayushya.noo/upload_service"
private val notificationPermissionRequestCode = 4202
private val mainHandler = Handler(Looper.getMainLooper())
private var newShareSink: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, shareIntentChannelName)
.setMethodCallHandler { call, result ->
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<Map<String, Any?>> {
val uris: List<Uri> = 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<Uri> {
val list = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}
return list ?: emptyList()
}
private fun uriMetadata(uri: Uri): Map<String, Any?> {
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<String>("files")
val serverUrl = call.argument<String>("serverUrl")
val username = call.argument<String>("username")
val authHeader = call.argument<String>("authHeader")
val remoteFolder = call.argument<String>("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)
}
}
@@ -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<ShareFile> {
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<ShareFile>,
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()
}
}