diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aed7ce0cf8..8240dddae3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -188,15 +188,23 @@ + + + android:targetActivity=".ui.forward.ShareActivity"> diff --git a/app/src/main/java/one/mixin/android/extension/ContextExtension.kt b/app/src/main/java/one/mixin/android/extension/ContextExtension.kt index 7d2c139470..48fafd9ce3 100644 --- a/app/src/main/java/one/mixin/android/extension/ContextExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/ContextExtension.kt @@ -1484,6 +1484,7 @@ fun Context.shareMedia( putExtra(Intent.EXTRA_STREAM, uri) } type = if (isVideo) "video/*" else "image/*" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } val name = getString(if (isVideo) R.string.Video else R.string.Photo) val chooser = Intent.createChooser(sendIntent, getString(R.string.share_to, name)) @@ -1493,7 +1494,7 @@ fun Context.shareMedia( grantUriPermission( packageName, uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + Intent.FLAG_GRANT_READ_URI_PERMISSION, ) } startActivity(chooser) diff --git a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt index 249e33c873..f8a1f9fcb1 100644 --- a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt @@ -43,6 +43,7 @@ import one.mixin.android.ui.setting.SettingActivity import one.mixin.android.ui.setting.member.MixinMemberUpgradeBottomSheetDialogFragment import one.mixin.android.ui.url.UrlInterpreterActivity import one.mixin.android.ui.web.WebActivity +import one.mixin.android.util.GsonHelper import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.analytics.AnalyticsTracker.TradeSource import one.mixin.android.util.analytics.AnalyticsTracker.TradeWallet @@ -51,6 +52,7 @@ import one.mixin.android.vo.AppCardData import one.mixin.android.vo.ForwardAction import one.mixin.android.vo.ForwardMessage import one.mixin.android.vo.ShareCategory +import one.mixin.android.vo.ShareImageData import one.mixin.android.vo.User import one.mixin.android.vo.generateConversationId import one.mixin.android.vo.getShareCategory @@ -695,6 +697,11 @@ fun Uri.handleSchemeSend( val data = this.getRawQueryParameter("data") val shareCategory = category?.getShareCategory() if (shareCategory != null && data != null) { + val message = parseSchemeShareMessage(shareCategory, data) + if (message == null) { + onError?.invoke("Error data") + return + } if (userId != null) { scope.launch { val identityNumber = Session.getAccount()?.identityNumber ?: return@launch @@ -705,7 +712,7 @@ fun Uri.handleSchemeSend( val bottomSheet = LinkBottomSheetDialogFragment.newInstance(this@handleSchemeSend.toString()) bottomSheet.showNow(supportFragmentManager, LinkBottomSheetDialogFragment.TAG) } else { - sendMessage(context, scope, user, currentConversation, message = ForwardMessage(shareCategory, String(Base64.decode(data)))) + sendMessage(context, scope, user, currentConversation, message = message) } } } else { @@ -713,7 +720,7 @@ fun Uri.handleSchemeSend( afterShareData?.invoke() val fragment = ShareMessageBottomSheetDialogFragment.newInstance( - ForwardMessage(shareCategory, String(Base64.decode(data))), + message, conversationId, app, host, @@ -733,6 +740,24 @@ fun Uri.handleSchemeSend( } } +internal fun parseSchemeShareMessage(category: ShareCategory, data: String): ForwardMessage? = + try { + val content = String(Base64.decode(data)) + if (category == ShareCategory.Image) { + val image = GsonHelper.customGson.fromJson(content, ShareImageData::class.java) + val uri = URI(image.url) + if ((uri.scheme.equals("https", true) || uri.scheme.equals("http", true)) && uri.host != null && uri.rawUserInfo == null) { + ForwardMessage(category, GsonHelper.customGson.toJson(ShareImageData(image.url))) + } else { + null + } + } else { + ForwardMessage(category, content) + } + } catch (_: Exception) { + null + } + fun sendMessage(context: Context, scope: CoroutineScope, user: User, currentConversation: String?, message: ForwardMessage) { val toConversation = generateConversationId(Session.getAccountId()!!, user.userId) ForwardActivity.show( diff --git a/app/src/main/java/one/mixin/android/job/ConvertDataJob.kt b/app/src/main/java/one/mixin/android/job/ConvertDataJob.kt index 50b02c68d2..fd22d5cbd4 100644 --- a/app/src/main/java/one/mixin/android/job/ConvertDataJob.kt +++ b/app/src/main/java/one/mixin/android/job/ConvertDataJob.kt @@ -10,6 +10,7 @@ import one.mixin.android.extension.createDocumentTemp import one.mixin.android.extension.getDocumentPath import one.mixin.android.extension.getExtensionName import one.mixin.android.extension.getFilePath +import one.mixin.android.util.ShareHelper import one.mixin.android.vo.MediaStatus import one.mixin.android.vo.Message import one.mixin.android.vo.MessageStatus @@ -35,6 +36,10 @@ class ConvertDataJob( removeJob() } + private fun releaseSource() { + message.mediaUrl?.let { ShareHelper.releaseJobSource(MixinApplication.appContext, Uri.parse(it)) } + } + override fun onRun() { if (isCancelled) { removeJob() @@ -73,6 +78,7 @@ class ConvertDataJob( ), ), ) + releaseSource() } } } diff --git a/app/src/main/java/one/mixin/android/job/ConvertVideoJob.kt b/app/src/main/java/one/mixin/android/job/ConvertVideoJob.kt index e50a1553c2..72203e603f 100644 --- a/app/src/main/java/one/mixin/android/job/ConvertVideoJob.kt +++ b/app/src/main/java/one/mixin/android/job/ConvertVideoJob.kt @@ -37,6 +37,7 @@ import one.mixin.android.extension.getMimeType import one.mixin.android.extension.getVideoModel import one.mixin.android.extension.getVideoPath import one.mixin.android.extension.nowInUtc +import one.mixin.android.util.ShareHelper import one.mixin.android.util.tickerFlow import one.mixin.android.util.video.VideoEditedInfo import one.mixin.android.vo.EncryptCategory @@ -82,6 +83,7 @@ class ConvertVideoJob( override fun onAdded() { val mimeType = getMimeType(uri) if (video == null) { + ShareHelper.releaseJobSource(MixinApplication.appContext, uri) return } if (mimeType != "video/mp4") { @@ -273,6 +275,7 @@ class ConvertVideoJob( messageDao.updateMediaDuration(duration.toString(), messageId) MessageFlow.update(message.conversationId, message.messageId) jobManager.addJobInBackground(SendAttachmentMessageJob(message)) + ShareHelper.releaseJobSource(MixinApplication.appContext, uri) } removeJob() diff --git a/app/src/main/java/one/mixin/android/ui/common/message/SendMessageHelper.kt b/app/src/main/java/one/mixin/android/ui/common/message/SendMessageHelper.kt index e84db33755..c12ded4c39 100644 --- a/app/src/main/java/one/mixin/android/ui/common/message/SendMessageHelper.kt +++ b/app/src/main/java/one/mixin/android/ui/common/message/SendMessageHelper.kt @@ -37,6 +37,7 @@ import one.mixin.android.repository.ConversationRepository import one.mixin.android.repository.UserRepository import one.mixin.android.util.Attachment import one.mixin.android.util.GsonHelper +import one.mixin.android.util.ShareHelper import one.mixin.android.util.image.Compressor import one.mixin.android.vo.AppCap import one.mixin.android.vo.AppCardData @@ -329,6 +330,7 @@ class SendMessageHelper MessageCategory.SIGNAL_DATA, MessageCategory.ENCRYPTED_DATA, ) + val source = ShareHelper.retainJobSource(MixinApplication.appContext, attachment.uri) val message = createAttachmentMessage( UUID.randomUUID().toString(), @@ -337,7 +339,7 @@ class SendMessageHelper category, null, attachment.filename, - attachment.uri.toString(), + source.toString(), attachment.mimeType, attachment.fileSize, nowInUtc(), @@ -348,7 +350,12 @@ class SendMessageHelper replyMessage?.messageId, replyMessage?.toQuoteMessageItem(), ) - jobManager.addJobInBackground(ConvertDataJob(message)) + try { + jobManager.addJobInBackground(ConvertDataJob(message)) + } catch (e: Exception) { + ShareHelper.releaseJobSource(MixinApplication.appContext, source) + throw e + } } fun sendAudioMessage( @@ -462,7 +469,13 @@ class SendMessageHelper replyMessage: MessageItem? = null, ) { val mid = messageId ?: UUID.randomUUID().toString() - jobManager.addJobInBackground(ConvertVideoJob(conversationId, senderId, uri, start, end, encryptCategory, mid, createdAt, replyMessage)) + val source = ShareHelper.retainJobSource(MixinApplication.appContext, uri) + try { + jobManager.addJobInBackground(ConvertVideoJob(conversationId, senderId, source, start, end, encryptCategory, mid, createdAt, replyMessage)) + } catch (e: Exception) { + ShareHelper.releaseJobSource(MixinApplication.appContext, source) + throw e + } } fun sendRecallMessage( diff --git a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt index b2da72bf84..f27fac2573 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt @@ -1236,7 +1236,12 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { showError() return } else { - d.base64RawURLDecode() + try { + d.base64RawURLDecode() + } catch (_: IllegalArgumentException) { + showError() + return + } } } else { null diff --git a/app/src/main/java/one/mixin/android/ui/forward/ForwardActivity.kt b/app/src/main/java/one/mixin/android/ui/forward/ForwardActivity.kt index 36ccbe2f7e..b478282db9 100644 --- a/app/src/main/java/one/mixin/android/ui/forward/ForwardActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/forward/ForwardActivity.kt @@ -14,10 +14,8 @@ import one.mixin.android.R import one.mixin.android.extension.colorFromAttribute import one.mixin.android.extension.replaceFragment import one.mixin.android.extension.toast -import one.mixin.android.session.Session import one.mixin.android.ui.common.BlazeBaseActivity import one.mixin.android.ui.conversation.ConversationActivity -import one.mixin.android.util.ShareHelper import one.mixin.android.util.SystemUIManager import one.mixin.android.vo.ForwardAction import one.mixin.android.vo.ForwardMessage @@ -151,31 +149,8 @@ class ForwardActivity : BlazeBaseActivity() { ) replaceFragment(f, R.id.container, ForwardFragment.TAG) } else { - if (Session.getAccount() == null) { - toast(R.string.Not_logged_in) - finish() - return - } - val forwardMessageList = ShareHelper.get().generateForwardMessageList(intent) - val conversationId = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && intent.hasExtra(Intent.EXTRA_SHORTCUT_ID)) { - intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID) - } else { - null - } - if (!forwardMessageList.isNullOrEmpty()) { - replaceFragment( - ForwardFragment.newInstance( - forwardMessageList, - ForwardAction.System(conversationId, getString(R.string.Share)), - ), - R.id.container, - ForwardFragment.TAG, - ) - } else { - toast(R.string.Share_error) - finish() - } + toast(R.string.Share_error) + finish() } } } diff --git a/app/src/main/java/one/mixin/android/ui/forward/ForwardFragment.kt b/app/src/main/java/one/mixin/android/ui/forward/ForwardFragment.kt index 5f2bf7bf20..dba45c298b 100644 --- a/app/src/main/java/one/mixin/android/ui/forward/ForwardFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/forward/ForwardFragment.kt @@ -25,17 +25,14 @@ import com.bumptech.glide.Glide import com.timehop.stickyheadersrecyclerview.StickyRecyclerHeadersDecoration import com.uber.autodispose.autoDispose import dagger.hilt.android.AndroidEntryPoint -import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.MixinApplication import one.mixin.android.R -import one.mixin.android.RxBus import one.mixin.android.crypto.Base64 import one.mixin.android.databinding.FragmentForwardBinding -import one.mixin.android.event.AppAuthEvent import one.mixin.android.extension.base64Encode import one.mixin.android.extension.copyFromInputStream import one.mixin.android.extension.getExtensionName @@ -217,9 +214,7 @@ class ForwardFragment : BaseFragment(R.layout.fragment_forward) { val cid = action.conversationId if (cid != null) { when (action) { - is ForwardAction.System -> { - sendDirectMessages(cid) - } + is ForwardAction.System -> Unit else -> { sendMessage(listOf(SelectItem(cid, null))) requireActivity().finish() @@ -301,19 +296,6 @@ class ForwardFragment : BaseFragment(R.layout.fragment_forward) { binding.searchEt.addTextChangedListener(mWatcher) loadData() - - RxBus.listen(AppAuthEvent::class.java) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(destroyScope) - .subscribe { - if (action is ForwardAction.System && !needOpenEditor()) { - action.conversationId?.let { cid -> - lifecycleScope.launch { - sendAndGo2Chat(cid) - } - } - } - } } private fun loadData() = @@ -341,6 +323,13 @@ class ForwardFragment : BaseFragment(R.layout.fragment_forward) { adapter.sourceFriends = friends adapter.sourceBots = bots + if (action is ForwardAction.System) { + val directShareTarget = action.conversationId + conversations.firstOrNull { it.conversationId == directShareTarget }?.let { + adapter.selectItem.add(it) + setForwardText() + } + } adapter.changeData() } @@ -756,29 +745,6 @@ class ForwardFragment : BaseFragment(R.layout.fragment_forward) { } } - private fun sendDirectMessages(cid: String) = - lifecycleScope.launch { - if (needOpenEditor()) { - editorPreserver = EditorPreserver(messages[0], listOf(SelectItem(cid, null))) - editAndSend(requireNotNull(editorPreserver?.forwardMessage)) - return@launch - } - - if (!MixinApplication.get().checkAndShowAppAuth(requireActivity())) { - sendAndGo2Chat(cid) - } - } - - private suspend fun sendAndGo2Chat(cid: String) { - val err = sendMessageInternal(SelectItem(cid, null)) - if (err.isNullOrEmpty()) { - toast(R.string.Message_sent) - } - MainActivity.reopen(requireContext()) - activity?.finish() - ConversationActivity.show(requireContext(), cid) - } - private fun updateDynamicShortcuts(selectItems: ArrayList) = lifecycleScope.launch { val shortcuts = mutableListOf() diff --git a/app/src/main/java/one/mixin/android/ui/forward/ShareActivity.kt b/app/src/main/java/one/mixin/android/ui/forward/ShareActivity.kt new file mode 100644 index 0000000000..da4503440c --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/forward/ShareActivity.kt @@ -0,0 +1,86 @@ +package one.mixin.android.ui.forward + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import one.mixin.android.R +import one.mixin.android.extension.colorFromAttribute +import one.mixin.android.extension.replaceFragment +import one.mixin.android.extension.toast +import one.mixin.android.session.Session +import one.mixin.android.ui.common.BlazeBaseActivity +import one.mixin.android.util.ShareHelper +import one.mixin.android.util.SystemUIManager +import one.mixin.android.vo.ForwardAction +import java.io.File +import java.util.UUID + +@AndroidEntryPoint +class ShareActivity : BlazeBaseActivity() { + private var stagingDirectory: File? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + SystemUIManager.setSafePadding(window, colorFromAttribute(R.attr.bg_white), imePadding = true) + setContentView(R.layout.activity_contact) + if (Session.getAccount() == null) { + toast(R.string.Not_logged_in) + finish() + return + } + val restored = supportFragmentManager.findFragmentByTag(ForwardFragment.TAG) != null + if (!restored) { + savedInstanceState?.getString(STAGING_DIRECTORY)?.let { + File(File(cacheDir, ShareHelper.STAGING_DIRECTORY), it).deleteRecursively() + } + } + val directoryName = savedInstanceState?.getString(STAGING_DIRECTORY)?.takeIf { restored } ?: UUID.randomUUID().toString() + val directory = File(File(cacheDir, ShareHelper.STAGING_DIRECTORY), directoryName) + stagingDirectory = directory + if (restored) return + + lifecycleScope.launch { + try { + val messages = withContext(Dispatchers.IO) { + ShareHelper.get().generateForwardMessageList(this@ShareActivity, intent, directory) + } + require(!messages.isNullOrEmpty()) + val shortcut = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID) + } else { + null + } + replaceFragment( + ForwardFragment.newInstance(messages, ForwardAction.System(shortcut, getString(R.string.Share))), + R.id.container, + ForwardFragment.TAG, + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + toast(R.string.Share_error) + finish() + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(STAGING_DIRECTORY, stagingDirectory?.name) + super.onSaveInstanceState(outState) + } + + override fun onDestroy() { + super.onDestroy() + if (!isChangingConfigurations) stagingDirectory?.deleteRecursively() + } + + companion object { + private const val STAGING_DIRECTORY = "staging_directory" + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt index 6ab0ad50b0..e6569a74d2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt @@ -953,7 +953,11 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba fun handlerCode(intent: Intent) { if (intent.hasExtra(SCAN)) { - val scan = intent.getStringExtra(SCAN)!! + val scan = intent.getStringExtra(SCAN) + if (scan == null) { + clearCodeAfterConsume(intent, SCAN) + return + } bottomSheet?.dismiss() showScanBottom(scan) clearCodeAfterConsume(intent, SCAN) @@ -1122,7 +1126,11 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba }, ) } else if (intent.hasExtra(WALLET_CONNECT)) { - val wcUrl = requireNotNull(intent.getStringExtra(WALLET_CONNECT)) + val wcUrl = intent.getStringExtra(WALLET_CONNECT) + if (wcUrl == null) { + clearCodeAfterConsume(intent, WALLET_CONNECT) + return + } WalletConnect.connect(wcUrl) } } diff --git a/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt b/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt index 4754219a15..3c4ccb99cc 100644 --- a/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt @@ -141,7 +141,9 @@ class ScanFragment : BaseCameraScanFragment() { result.result?.barcodes?.let { results -> binding.ivResult.setImageBitmap(previewView.bitmap) val points = mutableListOf() + val values = mutableListOf() for (barcode in results) { + val displayValue = barcode.displayValue ?: continue barcode.boundingBox?.let { box -> val point = PointUtils.transform( @@ -153,6 +155,7 @@ class ScanFragment : BaseCameraScanFragment() { binding.viewfinderView.height, ) points.add(point) + values.add(displayValue) } } Timber.e("$width - $height $points") @@ -160,12 +163,12 @@ class ScanFragment : BaseCameraScanFragment() { binding.viewfinderView.setOnItemClickListener( object : ViewfinderView.OnItemClickListener { override fun onItemClick(position: Int) { - handleAnalysis(results[position].displayValue!!) + values.getOrNull(position)?.let(::handleAnalysis) } }, ) - if (points.size == 1) { - handleAnalysis(results[0].displayValue!!) + if (values.size == 1) { + handleAnalysis(values[0]) } } } diff --git a/app/src/main/java/one/mixin/android/ui/url/UrlInterpreterActivity.kt b/app/src/main/java/one/mixin/android/ui/url/UrlInterpreterActivity.kt index 45405212e9..1a1f58b740 100644 --- a/app/src/main/java/one/mixin/android/ui/url/UrlInterpreterActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/url/UrlInterpreterActivity.kt @@ -27,6 +27,9 @@ import one.mixin.android.ui.web.WebActivity import one.mixin.android.web3.convertWcLink import timber.log.Timber +internal fun referralCodeFromMixinPath(pathSegments: List): String = + pathSegments.getOrNull(1).orEmpty() + @AndroidEntryPoint class UrlInterpreterActivity : BaseActivity() { companion object { @@ -119,7 +122,13 @@ class UrlInterpreterActivity : BaseActivity() { private fun interpretIntent(uri: Uri) { when (uri.host) { REFERRALS -> { - InputReferralBottomSheetDialogFragment.newInstance(uri.pathSegments.first()).showNow(supportFragmentManager, InputReferralBottomSheetDialogFragment.TAG) + val referral = uri.pathSegments.firstOrNull() + if (referral == null) { + toast(R.string.Invalid_Link) + finish() + return + } + InputReferralBottomSheetDialogFragment.newInstance(referral).showNow(supportFragmentManager, InputReferralBottomSheetDialogFragment.TAG) } USER, APPS -> uri.checkUserOrApp(this, supportFragmentManager, lifecycleScope) CODE, PAY, ADDRESS, SNAPSHOTS, CONVERSATIONS, TIP, SWAP -> { @@ -148,6 +157,8 @@ class UrlInterpreterActivity : BaseActivity() { afterShareText = { finish() }, onError = { err -> Timber.e(IllegalStateException(err)) + toast(R.string.Invalid_Link) + finish() }, ) } @@ -159,9 +170,19 @@ class UrlInterpreterActivity : BaseActivity() { finish() } MIXIN -> { - val path = uri.pathSegments.first() + val path = uri.pathSegments.firstOrNull() + if (path == null) { + toast(R.string.Invalid_Link) + finish() + return + } if (path.equals(REFERRALS,true)) { - InputReferralBottomSheetDialogFragment.newInstance(uri.pathSegments.last()).showNow(supportFragmentManager, InputReferralBottomSheetDialogFragment.TAG) + InputReferralBottomSheetDialogFragment + .newInstance(referralCodeFromMixinPath(uri.pathSegments)) + .showNow( + supportFragmentManager, + InputReferralBottomSheetDialogFragment.TAG, + ) } else if (path.equals(BUY, true)) { MainActivity.showWallet(this, buy = true) finish() diff --git a/app/src/main/java/one/mixin/android/util/ShareHelper.kt b/app/src/main/java/one/mixin/android/util/ShareHelper.kt index c58b8cc813..9786ebd1d6 100644 --- a/app/src/main/java/one/mixin/android/util/ShareHelper.kt +++ b/app/src/main/java/one/mixin/android/util/ShareHelper.kt @@ -1,19 +1,34 @@ package one.mixin.android.util +import android.content.ContentResolver +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Process +import android.webkit.MimeTypeMap +import androidx.core.content.IntentCompat +import androidx.core.net.toUri +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import one.mixin.android.extension.getFileName -import one.mixin.android.extension.getParcelableArrayListCompat -import one.mixin.android.extension.getParcelableExtraCompat import one.mixin.android.vo.ForwardCategory import one.mixin.android.vo.ForwardMessage import one.mixin.android.vo.ShareCategory import one.mixin.android.vo.ShareImageData -import one.mixin.android.vo.addTo -import one.mixin.android.vo.systemMediaToMessage +import one.mixin.android.websocket.DataMessagePayload +import one.mixin.android.websocket.VideoMessagePayload +import java.io.File +import java.io.InputStream +import java.io.OutputStream class ShareHelper { companion object { + internal const val STAGING_DIRECTORY = "external-share" + private const val JOB_DIRECTORY = "external-share-jobs" + private const val MAX_SHARE_ITEMS = 32 + private const val MAX_SHARE_BYTES = 1024 * 1024 * 1024L + @Volatile private var INSTANCE: ShareHelper? = null @@ -21,63 +36,100 @@ class ShareHelper { INSTANCE ?: synchronized(this) { INSTANCE ?: ShareHelper().also { INSTANCE = it } } + + fun retainJobSource(context: Context, uri: Uri): Uri { + if (uri.scheme != ContentResolver.SCHEME_FILE) return uri + val source = File(uri.path ?: return uri).canonicalFile + if (source.parentFile?.parentFile != File(context.cacheDir, STAGING_DIRECTORY).canonicalFile) return uri + val directory = File(context.filesDir, JOB_DIRECTORY).apply { mkdirs() } + val target = File.createTempFile("share-", ".${source.extension}", directory) + try { + source.copyTo(target, overwrite = true) + return target.toUri() + } catch (e: Exception) { + target.delete() + throw e + } + } + + fun releaseJobSource(context: Context, uri: Uri) { + if (uri.scheme != ContentResolver.SCHEME_FILE) return + val source = File(uri.path ?: return).canonicalFile + if (source.parentFile == File(context.filesDir, JOB_DIRECTORY).canonicalFile) source.delete() + } } - fun generateForwardMessageList(intent: Intent): ArrayList? { + suspend fun generateForwardMessageList(context: Context, intent: Intent, directory: File): ArrayList? { val action = intent.action - val type = intent.type - if (action == null || type == null) { - return null + val type = intent.type ?: return null + if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null + if (action == Intent.ACTION_SEND && type == "text/plain") { + intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotEmpty() }?.let { + return arrayListOf(ForwardMessage(ShareCategory.Text, it)) + } + } + val clipData = intent.clipData + require((clipData?.itemCount ?: 0) <= MAX_SHARE_ITEMS) + val clipUris = (0 until (clipData?.itemCount ?: 0)).mapNotNull { clipData?.getItemAt(it)?.uri } + val streams = if (action == Intent.ACTION_SEND) { + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { listOf(it) } + } else { + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) } + val uris = streams ?: clipUris + require(uris.isNotEmpty() && uris.size <= MAX_SHARE_ITEMS) + require(action != Intent.ACTION_SEND || uris.size == 1) + require(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + uris.forEach { uri -> + require(clipData == null || uri in clipUris) + require(uri.scheme == ContentResolver.SCHEME_CONTENT) + val authority = uri.authority?.substringAfterLast('@') ?: throw IllegalArgumentException() + require(!authority.equals("${context.packageName}.provider", ignoreCase = true)) + val provider = context.packageManager.resolveContentProvider(authority, 0) ?: throw IllegalArgumentException() + require(provider.applicationInfo.uid != context.applicationInfo.uid) + require(context.checkUriPermission(uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_READ_URI_PERMISSION) == PackageManager.PERMISSION_GRANTED) + } + require(directory.mkdirs() || directory.isDirectory) val result = arrayListOf() - if (Intent.ACTION_SEND == action) { - if ("text/plain" == type) { - val text = intent.getStringExtra(Intent.EXTRA_TEXT) - if (text.isNullOrEmpty()) { - intent.getParcelableExtraCompat(Intent.EXTRA_STREAM, Uri::class.java)?.systemMediaToMessage(ForwardCategory.Data)?.addTo(result) - } else { - ForwardMessage(ShareCategory.Text, text).addTo(result) - } - } else if (type.startsWith("image/")) { - val imageUri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM, Uri::class.java) - ForwardMessage(ShareCategory.Image, GsonHelper.customGson.toJson(ShareImageData(imageUri.toString()))).addTo(result) - } else if (type.startsWith("video/")) { - val videoUri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM, Uri::class.java) - videoUri?.systemMediaToMessage(ForwardCategory.Video)?.addTo(result) - } else if (type.startsWith("application/") || type.startsWith("audio/")) { - intent.getParcelableExtraCompat(Intent.EXTRA_STREAM, Uri::class.java)?.let { - val fileName = it.getFileName() - it.systemMediaToMessage( - ForwardCategory.Data, - fileName, - type, - ).addTo(result) - } - } else { - val dataUri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM, Uri::class.java) - dataUri?.systemMediaToMessage(ForwardCategory.Data, dataUri.getFileName())?.addTo(result) - } - } else if (Intent.ACTION_SEND_MULTIPLE == action) { - when { - type.startsWith("image/") -> { - intent.getParcelableArrayListCompat(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { item -> - item.systemMediaToMessage(ShareCategory.Image).addTo(result) - } + var remainingBytes = MAX_SHARE_BYTES + try { + uris.forEach { uri -> + currentCoroutineContext().ensureActive() + val name = uri.getFileName(context) + val mime = context.contentResolver.getType(uri) ?: type + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) + ?: name.substringAfterLast('.', "").takeIf { it.matches(Regex("[a-zA-Z0-9]{1,10}")) } + ?: "bin" + val file = File.createTempFile("share-", ".$extension", directory) + context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) + file.outputStream().use { output -> remainingBytes -= copyShareContent(input, output, remainingBytes) } } - type.startsWith("video/") -> { - intent.getParcelableArrayListCompat(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { item -> - item.systemMediaToMessage(ForwardCategory.Video).addTo(result) - } - } - else -> { - intent.getParcelableArrayListCompat(Intent.EXTRA_STREAM, Uri::class.java)?.let { list -> - list.forEach { item -> - item.systemMediaToMessage(ForwardCategory.Data, item.getFileName()).addTo(result) - } - } + val localUri = file.toUri().toString() + val message = when { + type.startsWith("image/") -> ForwardMessage(ShareCategory.Image, GsonHelper.customGson.toJson(ShareImageData(localUri))) + type.startsWith("video/") -> ForwardMessage(ForwardCategory.Video, GsonHelper.customGson.toJson(VideoMessagePayload(localUri))) + else -> ForwardMessage(ForwardCategory.Data, GsonHelper.customGson.toJson(DataMessagePayload(localUri, name, if ('*' in type) mime else type, file.length()))) } + result.add(message) } + return result + } catch (e: Exception) { + directory.deleteRecursively() + throw e } - return result + } +} + +internal suspend fun copyShareContent(input: InputStream, output: OutputStream, maxBytes: Long): Long { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count == -1) return total + require(count.toLong() <= maxBytes - total) + output.write(buffer, 0, count) + total += count } } diff --git a/app/src/test/java/one/mixin/android/extension/SchemeShareMessageTest.kt b/app/src/test/java/one/mixin/android/extension/SchemeShareMessageTest.kt new file mode 100644 index 0000000000..f089d46a2a --- /dev/null +++ b/app/src/test/java/one/mixin/android/extension/SchemeShareMessageTest.kt @@ -0,0 +1,38 @@ +package one.mixin.android.extension + +import one.mixin.android.util.GsonHelper +import one.mixin.android.vo.ShareCategory +import one.mixin.android.vo.ShareImageData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Base64 + +class SchemeShareMessageTest { + @Test + fun rejectsLocalImageSourcesBeforeRecipientBranching() { + listOf( + "file:///data/user/0/app/files/private.jpg", + "content://one.mixin.messenger.provider/root/private.jpg", + "content://0@one.mixin.messenger.provider/root/private.jpg", + "/data/user/0/app/files/private.jpg", + "android.resource://one.mixin.messenger/1", + "https:/missing-host.jpg", + "https://user@example.com/image.jpg", + ).forEach { + assertNull(parseSchemeShareMessage(ShareCategory.Image, encodedImage(it))) + } + } + + @Test + fun acceptsRemoteImagesAndDropsInternalAttachmentCapabilities() { + val result = parseSchemeShareMessage(ShareCategory.Image, encodedImage("https://example.com/image.jpg", "internal-attachment"))!! + val image = GsonHelper.customGson.fromJson(result.content, ShareImageData::class.java) + assertEquals("https://example.com/image.jpg", image.url) + assertNull(image.attachmentExtra) + assertNull(parseSchemeShareMessage(ShareCategory.Image, "not base64")) + } + + private fun encodedImage(url: String, attachmentExtra: String? = null) = + Base64.getEncoder().encodeToString(GsonHelper.customGson.toJson(ShareImageData(url, attachmentExtra)).toByteArray()) +} diff --git a/app/src/test/java/one/mixin/android/ui/url/UrlInterpreterActivityTest.kt b/app/src/test/java/one/mixin/android/ui/url/UrlInterpreterActivityTest.kt new file mode 100644 index 0000000000..08ea1c0b50 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/url/UrlInterpreterActivityTest.kt @@ -0,0 +1,25 @@ +package one.mixin.android.ui.url + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UrlInterpreterActivityTest { + @Test + fun emptyPathHasNoReferralCode() { + assertEquals("", referralCodeFromMixinPath(emptyList())) + } + + @Test + fun routeWithoutCodeDoesNotUseRouteNameAsReferral() { + assertEquals("", referralCodeFromMixinPath(listOf("referrals"))) + } + + @Test + fun readsReferralCodeImmediatelyAfterRoute() { + assertEquals("CODE123", referralCodeFromMixinPath(listOf("referrals", "CODE123"))) + assertEquals( + "CODE123", + referralCodeFromMixinPath(listOf("referrals", "CODE123", "ignored")), + ) + } +} diff --git a/app/src/test/java/one/mixin/android/util/ShareHelperTest.kt b/app/src/test/java/one/mixin/android/util/ShareHelperTest.kt new file mode 100644 index 0000000000..8c6510ea5b --- /dev/null +++ b/app/src/test/java/one/mixin/android/util/ShareHelperTest.kt @@ -0,0 +1,297 @@ +package one.mixin.android.util + +import android.app.Application +import android.content.ClipData +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import com.birbit.android.jobqueue.CancelReason +import kotlinx.coroutines.runBlocking +import one.mixin.android.MixinApplication +import one.mixin.android.job.ConvertDataJob +import one.mixin.android.job.SendAttachmentMessageJob +import one.mixin.android.ui.forward.ForwardActivity +import one.mixin.android.vo.ForwardAction +import one.mixin.android.vo.ForwardCategory +import one.mixin.android.vo.ForwardMessage +import one.mixin.android.vo.MessageBuilder +import one.mixin.android.vo.MessageCategory +import one.mixin.android.vo.MessageStatus +import one.mixin.android.vo.ShareCategory +import one.mixin.android.vo.ShareImageData +import one.mixin.android.vo.absolutePath +import one.mixin.android.websocket.DataMessagePayload +import one.mixin.android.websocket.VideoMessagePayload +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class ShareHelperTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val granted = mutableSetOf() + private lateinit var context: Context + private lateinit var provider: ShareProvider + private val uri = Uri.parse("content://external.share.provider/item") + + @Before + fun setUp() { + val application = ApplicationProvider.getApplicationContext() + context = object : ContextWrapper(application) { + override fun checkUriPermission(uri: Uri, pid: Int, uid: Int, modeFlags: Int) = + if (uri in granted && modeFlags == Intent.FLAG_GRANT_READ_URI_PERMISSION) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED + } + provider = ShareProvider(temporaryFolder.newFile().apply { writeText("shared bytes") }) + registerProvider(uri.authority!!, context.applicationInfo.uid + 1) + granted.add(uri) + } + + @Test + fun publicTextIgnoresInternalCapabilities() = runBlocking { + val intent = Intent(Intent.ACTION_SEND).setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, "public text") + .putExtra(ForwardActivity.ARGS_ACTION, ForwardAction.Bot(userId = "recipient")) + .putParcelableArrayListExtra(ForwardActivity.ARGS_MESSAGES, arrayListOf(ForwardMessage(ForwardCategory.Audio, "private payload"))) + .putExtra(ForwardActivity.ARGS_TO_CONVERSATION, "conversation") + val result = parse(intent) + assertEquals(listOf(ForwardMessage(ShareCategory.Text, "public text")), result) + assertNull(parse(Intent().putExtras(intent))) + } + + @Test + fun rejectsLocalFilesOwnProvidersAndUserPrefixedAuthoritiesBeforeOpening() = runBlocking { + val ownAuthority = "${context.packageName}.provider" + registerProvider(ownAuthority, context.applicationInfo.uid) + val otherOwnedAuthority = "owned.share.provider" + registerProvider(otherOwnedAuthority, context.applicationInfo.uid) + assertEquals("Provider UID", context.applicationInfo.uid, context.packageManager.resolveContentProvider(otherOwnedAuthority, 0)!!.applicationInfo.uid) + listOf( + Uri.fromFile(temporaryFolder.newFile()), + Uri.parse("content://$ownAuthority/root/private"), + Uri.parse("content://0@$ownAuthority/root/private"), + Uri.parse("content://0%40$ownAuthority/root/private"), + Uri.parse("content://0@${ownAuthority.uppercase()}/root/private"), + Uri.parse("content://$otherOwnedAuthority/private"), + Uri.parse("CONTENT://$ownAuthority/root/private"), + Uri.parse("android.resource://${context.packageName}/1"), + ).forEach { forbidden -> + granted.add(forbidden) + assertRejected(shareIntent(forbidden)) + assertEquals("Opened $forbidden", 0, provider.openCount) + assertEquals("Queried $forbidden", 0, provider.queryCount) + } + } + + @Test + fun requiresReadGrantAndMatchingClipData() = runBlocking { + assertRejected(shareIntent(uri).apply { flags = 0 }) + granted.clear() + assertRejected(shareIntent(uri)) + granted.add(uri) + assertRejected(shareIntent(uri).apply { clipData = ClipData.newRawUri("different", Uri.parse("content://external.share.provider/other")) }) + assertEquals(0, provider.openCount) + } + + @Test + fun stagesResolverBytesWithoutFollowingProviderDataPath() = runBlocking { + provider.privatePath = temporaryFolder.newFile().apply { writeText("private bytes") }.absolutePath + provider.name = "../../private.gif" + provider.mime = "image/gif" + val result = parse(shareIntent(uri, "image/gif"))!!.single() + assertEquals(ShareCategory.Image, result.category) + val data = GsonHelper.customGson.fromJson(result.content, ShareImageData::class.java) + val stagedFile = File(Uri.parse(data.url).path!!) + assertEquals("shared bytes", stagedFile.readText()) + assertNotEquals(provider.privatePath, stagedFile.absolutePath) + assertFalse(provider.queriedDataColumn) + assertNull(data.attachmentExtra) + } + + @Test + fun supportsClipOnlyMediaAndMultipleStreams() = runBlocking { + val video = parse(shareIntent(uri, "video/mp4").apply { removeExtra(Intent.EXTRA_STREAM) })!!.single() + assertEquals(ForwardCategory.Video, video.category) + assertEquals("shared bytes", File(Uri.parse(GsonHelper.customGson.fromJson(video.content, VideoMessagePayload::class.java).url).path!!).readText()) + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).setType("application/octet-stream") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri, uri)) + .apply { clipData = ClipData.newRawUri("share", uri) } + val files = parse(intent)!! + assertEquals(2, files.size) + files.forEach { + val data = GsonHelper.customGson.fromJson(it.content, DataMessagePayload::class.java) + assertEquals(ForwardCategory.Data, it.category) + assertEquals("shared bytes", File(Uri.parse(data.url).path!!).readText()) + assertEquals(12L, data.fileSize) + } + } + + @Test + fun rejectsOversizedListsAndCleansFailedImports() = runBlocking { + val tooMany = Intent(Intent.ACTION_SEND_MULTIPLE).setType("image/png") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(List(33) { uri })) + assertRejected(tooMany) + assertEquals(0, provider.openCount) + provider.failOpen = true + val directory = temporaryFolder.newFolder() + val result = runCatching { ShareHelper.get().generateForwardMessageList(context, shareIntent(uri), directory) } + assertTrue(result.isFailure) + assertFalse(directory.exists()) + } + + @Test + fun boundsActualBytesWithoutTrustingMetadata() = runBlocking { + val output = ByteArrayOutputStream() + assertEquals(3L, copyShareContent(ByteArrayInputStream(byteArrayOf(1, 2, 3)), output, 3)) + assertArrayEquals(byteArrayOf(1, 2, 3), output.toByteArray()) + assertTrue(runCatching { copyShareContent(ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)), ByteArrayOutputStream(), 3) }.isFailure) + } + + @Test + fun eachJobOwnsItsSourceAfterSharePageCleanup() { + val directory = File(File(context.cacheDir, ShareHelper.STAGING_DIRECTORY), UUID.randomUUID().toString()).apply { mkdirs() } + val source = File(directory, "source.mp4").apply { writeText("shared bytes") } + val first = ShareHelper.retainJobSource(context, source.toUri()) + val second = ShareHelper.retainJobSource(context, source.toUri()) + directory.deleteRecursively() + assertNotEquals(first, second) + assertEquals("shared bytes", File(first.path!!).readText()) + ShareHelper.releaseJobSource(context, first) + assertFalse(File(first.path!!).exists()) + assertEquals("shared bytes", File(second.path!!).readText()) + ShareHelper.releaseJobSource(context, second) + val ordinary = temporaryFolder.newFile() + assertEquals(ordinary.toUri(), ShareHelper.retainJobSource(context, ordinary.toUri())) + ShareHelper.releaseJobSource(context, ordinary.toUri()) + assertTrue(ordinary.exists()) + } + + @Test + fun queuedCancellationKeepsAttachmentSourceAvailableForRetry() { + MixinApplication.appContext = context + val directory = File(File(context.cacheDir, ShareHelper.STAGING_DIRECTORY), UUID.randomUUID().toString()).apply { mkdirs() } + val shared = File(directory, "shared.bin").apply { writeText("shared bytes") } + val source = ShareHelper.retainJobSource(context, shared.toUri()) + directory.deleteRecursively() + try { + val message = MessageBuilder( + "message-id", + "conversation-id", + "sender-id", + MessageCategory.PLAIN_DATA.name, + MessageStatus.SENDING.name, + "2026-09-07T00:00:00Z", + ) + .setMediaUrl(source.toString()) + .setName("shared.bin") + .setMediaMimeType("application/octet-stream") + .setMediaSize(12L) + .build() + + one.mixin.android.job.BaseJob::class.java.getDeclaredMethod("onCancel", Int::class.javaPrimitiveType, Throwable::class.java).apply { + isAccessible = true + }.invoke(ConvertDataJob(message), CancelReason.CANCELLED_WHILE_RUNNING, null) + + val retry = SendAttachmentMessageJob(message) + val retryUri = Uri.parse(retry.message.absolutePath(context)) + assertEquals(source, retryUri) + context.contentResolver.openInputStream(retryUri)!!.bufferedReader().use { + assertEquals("shared bytes", it.readText()) + } + } finally { + ShareHelper.releaseJobSource(context, source) + } + } + + private fun shareIntent(uri: Uri, type: String = "application/octet-stream") = + Intent(Intent.ACTION_SEND).setType(type) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, uri) + .apply { clipData = ClipData.newRawUri("share", uri) } + + private suspend fun parse(intent: Intent) = + ShareHelper.get().generateForwardMessageList(context, intent, temporaryFolder.newFolder()) + + private suspend fun assertRejected(intent: Intent) { + assertTrue(runCatching { parse(intent) }.isFailure) + } + + private fun registerProvider(authority: String, uid: Int) { + val info = ProviderInfo().apply { + this.authority = authority + packageName = if (uid == context.applicationInfo.uid) context.packageName else "external.share" + name = "$authority.ShareProvider" + applicationInfo = ApplicationInfo().apply { + this.uid = uid + packageName = if (uid == context.applicationInfo.uid) context.packageName else "external.share" + } + } + shadowOf(context.packageManager).addOrUpdateProvider(info) + provider.attachInfo(context, info) + ShadowContentResolver.registerProviderInternal(authority, provider) + } + + private class ShareProvider(private val file: File) : ContentProvider() { + var name = "shared.bin" + var mime = "application/octet-stream" + var privatePath = "unused" + var queriedDataColumn = false + var openCount = 0 + var queryCount = 0 + var failOpen = false + + override fun onCreate() = true + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor { + queryCount++ + val columns = projection ?: arrayOf(OpenableColumns.DISPLAY_NAME, "_data") + queriedDataColumn = queriedDataColumn || "_data" in columns + return MatrixCursor(columns).apply { addRow(columns.map { if (it == "_data") privatePath else name }) } + } + + override fun getType(uri: Uri) = mime + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + openCount++ + if (failOpen) throw IOException("read failed") + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + } +}