feat: add deep link supports #158

Closed
septs wants to merge 16 commits from septs:deep-link into master
5 changed files with 129 additions and 43 deletions

View file

@ -30,7 +30,24 @@
<activity <activity
android:exported="true" android:exported="true"
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
android:label="@string/download_wizard" /> android:label="@string/download_wizard">
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "lpa:" -->
<!-- for example: "LPA:1$..." -->
<!-- refs: https://www.iana.org/assignments/uri-schemes/prov/lpa -->
<data
android:scheme="lpa"
android:sspPrefix="1$" />
<data
android:scheme="LPA"
android:sspPrefix="1$" />
</intent-filter>
</activity>
<activity-alias <activity-alias
android:exported="true" android:exported="true"

View file

@ -21,7 +21,6 @@ import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.LocalProfileAssistant
class DownloadWizardActivity: BaseEuiccAccessActivity() { class DownloadWizardActivity: BaseEuiccAccessActivity() {
@ -63,15 +62,15 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
}) })
state = DownloadWizardState( state = DownloadWizardState(
null, currentStepFragmentClassName = null,
intent.getIntExtra("selectedLogicalSlot", 0), selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0),
"", smdp = "",
null, matchingId = null,
null, confirmationCode = null,
null, imei = null,
false, downloadStarted = false,
-1, downloadTaskID = -1,
null downloadError = null
) )
progressBar = requireViewById(R.id.progress) progressBar = requireViewById(R.id.progress)
@ -254,18 +253,28 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
} }
} }
internal fun getActivationCodeFromIntent(): String? {
val uri = intent.data ?: return null
if (uri.scheme != "lpa") return null
return uri.schemeSpecificPart
}
abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker { abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker {
protected val state: DownloadWizardState protected val state: DownloadWizardState
get() = (requireActivity() as DownloadWizardActivity).state get() = requireWizardActivity().state
abstract val hasNext: Boolean abstract val hasNext: Boolean
abstract val hasPrev: Boolean abstract val hasPrev: Boolean
abstract fun createNextFragment(): DownloadWizardStepFragment? abstract fun createNextFragment(): DownloadWizardStepFragment?
abstract fun createPrevFragment(): DownloadWizardStepFragment? abstract fun createPrevFragment(): DownloadWizardStepFragment?
protected fun requireWizardActivity(): DownloadWizardActivity {
return requireActivity() as DownloadWizardActivity
}
protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) { protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) {
val realNext = next ?: createNextFragment() val realNext = next ?: createNextFragment()
(requireActivity() as DownloadWizardActivity).showFragment( requireWizardActivity().showFragment(
realNext!!, realNext!!,
R.anim.slide_in_right, R.anim.slide_in_right,
R.anim.slide_out_left R.anim.slide_out_left
@ -273,11 +282,11 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
} }
protected fun hideProgressBar() { protected fun hideProgressBar() {
(requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE requireWizardActivity().progressBar.visibility = View.GONE
} }
protected fun showProgressBar(progressValue: Int) { protected fun showProgressBar(progressValue: Int) {
(requireActivity() as DownloadWizardActivity).progressBar.apply { requireWizardActivity().progressBar.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
if (progressValue >= 0) { if (progressValue >= 0) {
isIndeterminate = false isIndeterminate = false
@ -289,7 +298,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
} }
protected fun refreshButtons() { protected fun refreshButtons() {
(requireActivity() as DownloadWizardActivity).refreshButtons() requireWizardActivity().refreshButtons()
} }
open fun beforeNext() {} open fun beforeNext() {}

View file

@ -35,8 +35,12 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment = override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
DownloadWizardProgressFragment() DownloadWizardProgressFragment()
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment = override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment {
DownloadWizardMethodSelectFragment() if (requireWizardActivity().getActivationCodeFromIntent() != null) {
Review

This should be a boolean flag in the persisted state of download wizard.

This should be a boolean flag in the persisted state of download wizard.
return DownloadWizardSlotSelectFragment()
}
return DownloadWizardMethodSelectFragment()
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,

View file

@ -4,6 +4,7 @@ import android.app.AlertDialog
import android.content.ClipboardManager import android.content.ClipboardManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -21,10 +22,16 @@ import com.journeyapps.barcodescanner.ScanOptions
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() { class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
const val TAG = "DownloadWizardMethodSelectFragment"
}
data class DownloadMethod( data class DownloadMethod(
val iconRes: Int, val iconRes: Int,
val titleRes: Int, val titleRes: Int,
@ -89,6 +96,11 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment = override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
DownloadWizardSlotSelectFragment() DownloadWizardSlotSelectFragment()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requireWizardActivity().getActivationCodeFromIntent()?.let(::processLpaString)
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -125,28 +137,30 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
} }
private fun processLpaString(input: String) { private fun processLpaString(input: String) {
if (runBlocking { preferenceRepository.verboseLoggingFlow.first() }) {
Log.i(TAG, "Processing LPA string: $input")
}
try { try {
val parsed = ActivationCode.fromString(input) val parsed = ActivationCode.fromString(input)
state.smdp = parsed.address state.smdp = parsed.address
state.matchingId = parsed.matchingId state.matchingId = parsed.matchingId
if (parsed.confirmationCodeRequired) { if (parsed.confirmationCodeRequired) {
AlertDialog.Builder(requireContext()).apply { AlertDialog.Builder(requireContext())
setTitle(R.string.profile_download_required_confirmation_code) .setTitle(R.string.profile_download_required_confirmation_code)
setMessage(R.string.profile_download_required_confirmation_code_message) .setMessage(R.string.profile_download_required_confirmation_code_message)
setCancelable(true) .setCancelable(true)
setPositiveButton(android.R.string.ok, null) .setPositiveButton(android.R.string.ok, null)
show() .show()
}
} }
gotoNextFragment(DownloadWizardDetailsFragment()) gotoNextFragment(DownloadWizardDetailsFragment())
} catch (e: IllegalArgumentException) { } catch (e: IllegalStateException) {
AlertDialog.Builder(requireContext()).apply { Log.d(TAG, "Failed to parse LPA string", e)
setTitle(R.string.profile_download_incorrect_lpa_string) AlertDialog.Builder(requireContext())
setMessage(R.string.profile_download_incorrect_lpa_string_message) .setTitle(R.string.profile_download_incorrect_lpa_string)
setCancelable(true) .setMessage(R.string.profile_download_incorrect_lpa_string_message)
setNegativeButton(android.R.string.cancel, null) .setCancelable(true)
show() .setNegativeButton(android.R.string.cancel, null)
} .show()
} }
} }

View file

@ -7,20 +7,26 @@ data class ActivationCode(
val confirmationCodeRequired: Boolean = false, val confirmationCodeRequired: Boolean = false,
) { ) {
companion object { companion object {
fun fromString(input: String): ActivationCode { fun fromString(token: String): ActivationCode {
val components = input.removePrefix("LPA:").split('$') val input = if (token.startsWith("LPA:", true)) token.drop(4) else token
if (components.size < 2 || components[0] != "1") { val components = input.split('$').map { it.trim().ifEmpty { null } }
throw IllegalArgumentException("Invalid activation code format") check(components.size >= 2) { "Invalid activation code format" }
} check(components[0] == "1") { "Invalid activation code version" }
return ActivationCode( return ActivationCode(
address = components[1].trim(), checkNotNull(components[1]) { "Invalid SM-DP+ address" },
matchingId = components.getOrNull(2)?.trim()?.ifBlank { null }, components.getOrNull(2),
oid = components.getOrNull(3)?.trim()?.ifBlank { null }, components.getOrNull(3),
confirmationCodeRequired = components.getOrNull(4)?.trim() == "1" components.getOrNull(4) == "1",
) )
} }
} }
init {
check(isFQDN(address)) { "Invalid SM-DP+ address" }
check(matchingId == null || isMatchingID(matchingId)) { "Invalid Matching ID" }
check(oid == null || isObjectIdentifier(oid)) { "Invalid OID" }
}
override fun toString(): String { override fun toString(): String {
val parts = arrayOf( val parts = arrayOf(
"1", "1",
@ -31,4 +37,40 @@ data class ActivationCode(
) )
return parts.joinToString("$").trimEnd('$') return parts.joinToString("$").trimEnd('$')
} }
} }
/**
* SGP.22 4.1 Activation Code (v2.2.2, p111)
*
* FQDN (Fully Qualified Domain Name) of the SM-DP+ (e.g., SMDP.GSMA.COM)
* restricted to the Alphanumeric mode character set defined in table 5 of ISO/IEC 18004 [15]
* excluding '$'
*/
private fun isFQDN(input: CharSequence): Boolean {
if (input.isEmpty() || input.length > 255) return false
val parts = input.split('.')
if (parts.size < 2) return false
for (part in parts) {
if (part.isEmpty() || part.length > 63) return false
if (part.all { it.isLetterOrDigit() || it == '-' }) continue
return false
}
return true
}
/**
* SGP.22 4.1.1 Matching ID (v2.2.2, p112)
*
* Matching ID is a string of alphanumeric characters and hyphens.
*/
private fun isMatchingID(input: CharSequence) =
input.all { it.isLetterOrDigit() || it == '-' }
/**
* SGP.22 4.1 Activation Code (v2.2.2, p111)
*
* SM-DP+ OID in the CERT.DPauth.ECDSA
*/
private fun isObjectIdentifier(input: CharSequence) = input.length < 255 &&
input.count { it == '.' } > 2 &&
input.split('.').all { it.isNotEmpty() && it.all(Char::isDigit) }