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
+ }
+}