Add GET_CONTENT picker support, fix account/login bugs, restyle selection UI
Build APK / build (push) Successful in 7m40s

- Let Noo be picked as a source (Files + Photos) by other apps' native
  file/photo pickers via Android's GET_CONTENT intent, mirroring the
  existing "Share to Noo" plumbing: MainActivity.kt/PickIntentService/
  PickRequest, ServerProvider.pickRequest/confirmPick/cancelPick, and
  pick-mode tap handling in FilesView/PhotosView. While picking, the
  bottom nav and MoreTabsButton are restricted to Files/Photos only.
- Fix two account bugs: testConnection() failures no longer delete a
  still-valid stored app password (only an actual 401 does), and
  switchAccount() now retries a nominally-active-but-logged-out account
  instead of no-op'ing - both were silently locking users out of "Continue
  as" with no way back in except removing and re-adding the account
  (now exposed via login_view.dart's inline remove button).
- Fix the login WebView: clear cookies before every load so "Add Account"
  can't silently reuse an existing session, and push it as a
  non-animated route to avoid a known WebView-as-PlatformView black-screen-
  on-pop issue under Impeller.
- Redesign the Files/Photos selection toolbar to take over the top app bar
  entirely (SyncedHeaderScaffold.selectionBar) instead of sitting as a
  second bar under the controls row, with a horizontally-scrollable,
  uniformly-colored actions row and the same one-shot scroll-hint nudge
  the controls row uses.
- Redesign ShareUploadView to share the same SyncedHeaderScaffold chrome
  as every tab, with the uploading-file summary (marqueed via the new
  shared MarqueeTitle widget) and upload button grouped into one
  rounded/elevated bottom sheet.
- Restyle ShareSheet to open on the same DetailsHeader every other
  per-item sheet uses, and fix a couple of small design-language drifts
  (hardcoded text style, a non-rounded icon).
- Settings' account card: icon buttons with tooltips instead of text
  buttons for refresh/logout/remove, divider removed.
This commit is contained in:
2026-09-17 23:36:54 -04:00
parent eee2730fbe
commit 44a23c3632
24 changed files with 1298 additions and 394 deletions
+23
View File
@@ -49,11 +49,34 @@
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
</intent-filter>
<!-- Lets Noo be picked as a source ("Google Photos", "Files", ...)
from another app's own file/photo picker chooser - handled by
MainActivity.kt/PickIntentService, not a plugin. A single
"*/*" filter matches any mimeType the caller requests
(image/*, video/*, or a generic file pick). -->
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.OPENABLE"/>
<data android:mimeType="*/*"/>
</intent-filter>
</activity>
<service
android:name=".ShareUploadService"
android:exported="false"
android:foregroundServiceType="dataSync"/>
<!-- Hands back the file(s) a caller picked via the GET_CONTENT filter
above - only the app's own cache/picker/ subfolder is exposed,
see file_paths.xml. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.picker.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -1,6 +1,7 @@
package dev.ayushya.noo
import android.Manifest
import android.content.ClipData
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
@@ -10,11 +11,13 @@ import android.os.Looper
import android.provider.OpenableColumns
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
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
import java.io.File
// FlutterFragmentActivity (not the default FlutterActivity) is required by
// local_auth's Android implementation, which hosts its biometric/device
@@ -38,10 +41,17 @@ 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 pickIntentChannelName = "dev.ayushya.noo/pick_intent"
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the
// Activity's base Context, which isn't attached yet while this class's
// fields are being constructed (crashes with an NPE if evaluated then).
private val pickerFileProviderAuthority by lazy { "$packageName.picker.fileprovider" }
private val mainHandler = Handler(Looper.getMainLooper())
private var newShareSink: EventChannel.EventSink? = null
private var newPickSink: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -73,6 +83,26 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, pickIntentChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"getPickRequest" -> result.success(extractPickRequest(intent))
"finishPick" -> finishPick(call, result)
"cancelPick" -> cancelPick(result)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, newPickChannelName)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
newPickSink = events
}
override fun onCancel(arguments: Any?) {
newPickSink = null
}
})
}
override fun onNewIntent(intent: Intent) {
@@ -82,6 +112,10 @@ class MainActivity : FlutterFragmentActivity() {
if (shared.isNotEmpty()) {
mainHandler.post { newShareSink?.success(shared) }
}
val pickRequest = extractPickRequest(intent)
if (pickRequest != null) {
mainHandler.post { newPickSink?.success(pickRequest) }
}
}
/// Cheap Uri metadata only (display name / size / mime type) via a
@@ -180,4 +214,73 @@ class MainActivity : FlutterFragmentActivity() {
ContextCompat.startForegroundService(this, serviceIntent)
result.success(null)
}
/// Non-null only when this Activity was launched (or re-delivered a new
/// Intent) as another app's GET_CONTENT picker - the mimeType filter and
/// multi-select flag the caller asked for, plus a best-effort display
/// name for the calling app to show in the "picking mode" banner.
private fun extractPickRequest(intent: Intent): Map<String, Any?>? {
if (intent.action != Intent.ACTION_GET_CONTENT) return null
val mimeType = intent.type ?: "*/*"
val allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
val callerPackage = callingPackage
val callerLabel = callerPackage?.let {
try {
val appInfo = packageManager.getApplicationInfo(it, 0)
packageManager.getApplicationLabel(appInfo).toString()
} catch (_: PackageManager.NameNotFoundException) {
it
}
}
return mapOf(
"mimeType" to mimeType,
"allowMultiple" to allowMultiple,
"callerLabel" to callerLabel,
)
}
/// Hands the already-downloaded local files (see PickIntentService/
/// ServerProvider.confirmPick - this Activity never touches the
/// Nextcloud server itself) back to the caller as content:// Uris
/// through this app's own FileProvider, then closes the picker.
private fun finishPick(call: MethodCall, result: MethodChannel.Result) {
val paths = call.argument<List<String>>("paths")
val mimeTypes = call.argument<List<String>>("mimeTypes") ?: emptyList()
if (paths.isNullOrEmpty()) {
result.error("no_files", "No files to return to the caller", null)
return
}
val uris = paths.map { path ->
FileProvider.getUriForFile(this, pickerFileProviderAuthority, File(path))
}
val resultIntent = Intent().apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// `Intent.setData()` and `.setType()` each silently null out the
// other field if called separately (Android framework behavior) -
// `setDataAndType` is the only way to have both survive on the same
// Intent, which every GET_CONTENT caller expects for `data`.
val firstMimeType = mimeTypes.getOrNull(0) ?: "*/*"
if (uris.size == 1) {
resultIntent.setDataAndType(uris[0], firstMimeType)
} else {
val clipMimeTypes = mimeTypes.ifEmpty { listOf("*/*") }.toTypedArray()
val clipData = ClipData("Noo picked files", clipMimeTypes, ClipData.Item(uris[0]))
for (i in 1 until uris.size) clipData.addItem(ClipData.Item(uris[i]))
resultIntent.clipData = clipData
// Some callers only read `data` even for a multi-item pick, so
// this still points at the first file as a fallback.
resultIntent.setDataAndType(uris[0], firstMimeType)
}
setResult(RESULT_OK, resultIntent)
result.success(null)
finish()
}
private fun cancelPick(result: MethodChannel.Result) {
setResult(RESULT_CANCELED)
result.success(null)
finish()
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Exposes only the app's own "picker" cache subfolder as content:// Uris -
used to hand a file back to another app that launched Noo as a
GET_CONTENT picker (see PickIntentService/MainActivity.kt). -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="picker" path="picker/" />
</paths>