Add GET_CONTENT picker support, fix account/login bugs, restyle selection UI
Build APK / build (push) Successful in 7m40s
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:
@@ -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>
|
||||
Reference in New Issue
Block a user