EuiccManagementFragment.kt 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. package im.angry.openeuicc.ui
  2. import android.annotation.SuppressLint
  3. import android.content.ClipData
  4. import android.content.ClipboardManager
  5. import android.content.Intent
  6. import android.os.Build
  7. import android.os.Bundle
  8. import android.text.method.PasswordTransformationMethod
  9. import android.view.LayoutInflater
  10. import android.view.Menu
  11. import android.view.MenuInflater
  12. import android.view.MenuItem
  13. import android.view.View
  14. import android.view.ViewGroup
  15. import android.widget.FrameLayout
  16. import android.widget.ImageButton
  17. import android.widget.PopupMenu
  18. import android.widget.TextView
  19. import android.widget.Toast
  20. import androidx.appcompat.app.AlertDialog
  21. import androidx.core.view.isVisible
  22. import androidx.core.view.updateLayoutParams
  23. import androidx.fragment.app.Fragment
  24. import androidx.lifecycle.lifecycleScope
  25. import androidx.recyclerview.widget.LinearLayoutManager
  26. import androidx.recyclerview.widget.RecyclerView
  27. import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
  28. import com.google.android.material.floatingactionbutton.FloatingActionButton
  29. import im.angry.openeuicc.common.R
  30. import im.angry.openeuicc.core.EuiccChannel
  31. import im.angry.openeuicc.service.EuiccChannelManagerService
  32. import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
  33. import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
  34. import im.angry.openeuicc.util.*
  35. import kotlinx.coroutines.Dispatchers
  36. import kotlinx.coroutines.TimeoutCancellationException
  37. import kotlinx.coroutines.flow.StateFlow
  38. import kotlinx.coroutines.flow.stateIn
  39. import kotlinx.coroutines.launch
  40. import kotlinx.coroutines.withContext
  41. import net.typeblog.lpac_jni.LocalProfileInfo
  42. import net.typeblog.lpac_jni.ProfileClass
  43. open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
  44. EuiccChannelFragmentMarker {
  45. companion object {
  46. const val TAG = "EuiccManagementFragment"
  47. fun newInstance(
  48. slotId: Int,
  49. portId: Int,
  50. seId: EuiccChannel.SecureElementId
  51. ): EuiccManagementFragment =
  52. newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId, seId)
  53. }
  54. private lateinit var swipeRefresh: SwipeRefreshLayout
  55. private lateinit var fab: FloatingActionButton
  56. private lateinit var profileList: RecyclerView
  57. private var logicalSlotId: Int = -1
  58. private lateinit var eid: String
  59. private var enabledProfile: LocalProfileInfo? = null
  60. private val adapter = EuiccProfileAdapter()
  61. // Marker for when this fragment might enter an invalid state
  62. // e.g. after a failed enable / disable operation
  63. private var invalid = false
  64. // Subscribe to settings we care about outside of coroutine contexts while initializing
  65. // This gives us access to the "latest" state without having to launch coroutines
  66. private lateinit var disableSafeguardFlow: StateFlow<Boolean>
  67. private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
  68. override fun onCreate(savedInstanceState: Bundle?) {
  69. super.onCreate(savedInstanceState)
  70. setHasOptionsMenu(true)
  71. }
  72. override fun onCreateView(
  73. inflater: LayoutInflater,
  74. container: ViewGroup?,
  75. savedInstanceState: Bundle?
  76. ): View {
  77. val view = inflater.inflate(R.layout.fragment_euicc, container, false)
  78. swipeRefresh = view.requireViewById(R.id.swipe_refresh)
  79. fab = view.requireViewById(R.id.fab)
  80. profileList = view.requireViewById(R.id.profile_list)
  81. val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
  82. val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
  83. setupRootViewSystemBarInsets(
  84. view, arrayOf(
  85. mainViewPaddingInsetHandler(profileList),
  86. { insets ->
  87. fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
  88. rightMargin = origFabMarginRight + insets.right
  89. bottomMargin = origFabMarginBottom + insets.bottom
  90. }
  91. }
  92. ))
  93. profileList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
  94. override fun onScrollStateChanged(view: RecyclerView, newState: Int) =
  95. if (newState == RecyclerView.SCROLL_STATE_IDLE) fab.show() else fab.hide()
  96. })
  97. return view
  98. }
  99. override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  100. super.onViewCreated(view, savedInstanceState)
  101. swipeRefresh.setOnRefreshListener { refresh() }
  102. profileList.adapter = adapter
  103. profileList.layoutManager =
  104. LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
  105. fab.setOnClickListener {
  106. val intent = DownloadWizardActivity.newIntent(requireContext(), slotId, seId)
  107. startActivity(intent)
  108. }
  109. }
  110. override fun onStart() {
  111. super.onStart()
  112. refresh()
  113. }
  114. override fun onEuiccProfilesChanged() {
  115. refresh()
  116. }
  117. override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  118. super.onCreateOptionsMenu(menu, inflater)
  119. inflater.inflate(R.menu.fragment_euicc, menu)
  120. }
  121. override fun onPrepareOptionsMenu(menu: Menu) {
  122. super.onPrepareOptionsMenu(menu)
  123. menu.findItem(R.id.show_notifications).isVisible =
  124. logicalSlotId != -1
  125. menu.findItem(R.id.euicc_info).isVisible =
  126. logicalSlotId != -1
  127. menu.findItem(R.id.euicc_memory_reset).isVisible =
  128. enabledProfile == null
  129. }
  130. override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
  131. R.id.show_notifications -> {
  132. Intent(requireContext(), NotificationsActivity::class.java).apply {
  133. putExtra("logicalSlotId", logicalSlotId)
  134. putExtra("seId", seId)
  135. startActivity(this)
  136. }
  137. true
  138. }
  139. R.id.euicc_info -> {
  140. Intent(requireContext(), EuiccInfoActivity::class.java).apply {
  141. putExtra("logicalSlotId", logicalSlotId)
  142. putExtra("seId", seId)
  143. startActivity(this)
  144. }
  145. true
  146. }
  147. R.id.euicc_memory_reset -> {
  148. EuiccMemoryResetFragment.newInstance(slotId, portId, seId, eid)
  149. .show(childFragmentManager, EuiccMemoryResetFragment.TAG)
  150. true
  151. }
  152. else -> super.onOptionsItemSelected(item)
  153. }
  154. protected open suspend fun onCreateFooterViews(
  155. parent: ViewGroup,
  156. profiles: List<LocalProfileInfo>
  157. ): List<View> =
  158. if (profiles.isEmpty()) {
  159. val view = layoutInflater.inflate(R.layout.footer_no_profile, parent, false)
  160. listOf(view)
  161. } else {
  162. listOf()
  163. }
  164. private fun refresh() {
  165. if (invalid) return
  166. swipeRefresh.isRefreshing = true
  167. lifecycleScope.launch {
  168. doRefresh()
  169. }
  170. }
  171. @SuppressLint("NotifyDataSetChanged")
  172. protected open suspend fun doRefresh() {
  173. ensureEuiccChannelManager()
  174. euiccChannelManagerService.waitForForegroundTask()
  175. if (!::disableSafeguardFlow.isInitialized) {
  176. disableSafeguardFlow =
  177. preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
  178. }
  179. if (!::unfilteredProfileListFlow.isInitialized) {
  180. unfilteredProfileListFlow =
  181. preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
  182. }
  183. val profiles = withEuiccChannel { channel ->
  184. logicalSlotId = channel.logicalSlotId
  185. eid = channel.lpa.eID
  186. enabledProfile = channel.lpa.profiles.enabled
  187. euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
  188. if (unfilteredProfileListFlow.value)
  189. channel.lpa.profiles
  190. else
  191. channel.lpa.profiles.operational
  192. }
  193. withContext(Dispatchers.Main) {
  194. adapter.profiles = profiles
  195. adapter.footerViews = onCreateFooterViews(profileList, profiles)
  196. adapter.notifyDataSetChanged()
  197. swipeRefresh.isRefreshing = false
  198. }
  199. }
  200. private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
  201. val resId = R.string.toast_profile_enable_failed
  202. Toast.makeText(context, resId, Toast.LENGTH_LONG).show()
  203. }
  204. private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
  205. swipeRefresh.isRefreshing = true
  206. fab.isEnabled = false
  207. lifecycleScope.launch {
  208. ensureEuiccChannelManager()
  209. euiccChannelManagerService.waitForForegroundTask()
  210. val err = euiccChannelManagerService
  211. .launchProfileSwitchTask(
  212. slotId, portId, seId, iccid, enable,
  213. reconnectTimeoutMillis = 30 * 1000
  214. )
  215. .waitDone()
  216. when (err) {
  217. null -> {}
  218. is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
  219. // This is only really fatal for internal eSIMs
  220. if (!isUsb) {
  221. withContext(Dispatchers.Main) {
  222. AlertDialog.Builder(requireContext()).apply {
  223. setMessage(R.string.profile_switch_did_not_refresh)
  224. setPositiveButton(android.R.string.ok) { dialog, _ ->
  225. dialog.dismiss()
  226. requireActivity().finish()
  227. }
  228. setOnDismissListener { _ ->
  229. requireActivity().finish()
  230. }
  231. show()
  232. }
  233. }
  234. }
  235. }
  236. is TimeoutCancellationException -> {
  237. withContext(Dispatchers.Main) {
  238. // Prevent this Fragment from being used again
  239. invalid = true
  240. // Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
  241. AlertDialog.Builder(requireContext()).apply {
  242. setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
  243. setPositiveButton(android.R.string.ok) { dialog, _ ->
  244. dialog.dismiss()
  245. requireActivity().finish()
  246. }
  247. setOnDismissListener { _ ->
  248. requireActivity().finish()
  249. }
  250. show()
  251. }
  252. }
  253. }
  254. else -> showSwitchFailureText()
  255. }
  256. refresh()
  257. fab.isEnabled = true
  258. }
  259. }
  260. protected open fun populatePopupWithProfileActions(
  261. popup: PopupMenu,
  262. profile: LocalProfileInfo
  263. ) {
  264. popup.inflate(R.menu.profile_options)
  265. if (!profile.isEnabled) return
  266. popup.menu.findItem(R.id.enable).isVisible = false
  267. popup.menu.findItem(R.id.delete).isVisible = false
  268. // We hide the disable option by default to avoid "bricking" some cards that won't get
  269. // recognized again by the phone's modem. However, we don't have that worry if we are
  270. // accessing it through a USB card reader, or when the user explicitly opted in
  271. if (!isUsb && !disableSafeguardFlow.value) return
  272. popup.menu.findItem(R.id.disable).isVisible = true
  273. }
  274. sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
  275. enum class Type(val value: Int) {
  276. PROFILE(0),
  277. FOOTER(1);
  278. companion object {
  279. fun fromInt(value: Int) =
  280. entries.first { it.value == value }
  281. }
  282. }
  283. }
  284. inner class FooterViewHolder : ViewHolder(FrameLayout(requireContext())) {
  285. init {
  286. itemView.layoutParams = ViewGroup.LayoutParams(
  287. ViewGroup.LayoutParams.MATCH_PARENT,
  288. ViewGroup.LayoutParams.WRAP_CONTENT
  289. )
  290. }
  291. fun attach(view: View) {
  292. view.parent?.let { (it as ViewGroup).removeView(view) }
  293. (itemView as FrameLayout).addView(view)
  294. }
  295. fun detach() {
  296. (itemView as FrameLayout).removeAllViews()
  297. }
  298. }
  299. inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
  300. private val iccid: TextView = root.requireViewById(R.id.iccid)
  301. private val name: TextView = root.requireViewById(R.id.name)
  302. private val state: TextView = root.requireViewById(R.id.state)
  303. private val provider: TextView = root.requireViewById(R.id.provider)
  304. private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
  305. private val profileClass: TextView = root.requireViewById(R.id.profile_class)
  306. private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
  307. private val profileSeqNumber: TextView = root.requireViewById(R.id.profile_sequence_number)
  308. init {
  309. iccid.setOnClickListener {
  310. if (iccid.transformationMethod == null) {
  311. iccid.transformationMethod = PasswordTransformationMethod.getInstance()
  312. } else {
  313. iccid.transformationMethod = null
  314. }
  315. }
  316. iccid.setOnLongClickListener {
  317. requireContext().getSystemService(ClipboardManager::class.java)!!
  318. .setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
  319. if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
  320. .makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
  321. .show()
  322. true
  323. }
  324. profileMenu.setOnClickListener {
  325. showOptionsMenu()
  326. }
  327. }
  328. private lateinit var profile: LocalProfileInfo
  329. private var canEnable: Boolean = false
  330. fun setProfile(profile: LocalProfileInfo) {
  331. this.profile = profile
  332. name.text = profile.displayName
  333. state.setText(
  334. if (profile.isEnabled) {
  335. R.string.profile_state_enabled
  336. } else {
  337. R.string.profile_state_disabled
  338. }
  339. )
  340. provider.text = profile.providerName
  341. profileClassLabel.isVisible = unfilteredProfileListFlow.value
  342. profileClass.isVisible = unfilteredProfileListFlow.value
  343. profileClass.setText(
  344. when (profile.profileClass) {
  345. ProfileClass.Testing -> R.string.profile_class_testing
  346. ProfileClass.Provisioning -> R.string.profile_class_provisioning
  347. ProfileClass.Operational -> R.string.profile_class_operational
  348. }
  349. )
  350. iccid.text = profile.iccid
  351. iccid.transformationMethod = PasswordTransformationMethod.getInstance()
  352. }
  353. fun setProfileSequenceNumber(index: Int) {
  354. profileSeqNumber.text = root.context.getString(
  355. R.string.profile_sequence_number_format,
  356. index,
  357. )
  358. }
  359. fun setEnabledProfile(enabledProfile: LocalProfileInfo?) {
  360. // cannot cross profile class enable profile
  361. // e.g: testing -> operational or operational -> testing
  362. canEnable = enabledProfile == null ||
  363. enabledProfile.profileClass == profile.profileClass
  364. }
  365. private fun showOptionsMenu() {
  366. // Prevent users from doing multiple things at once
  367. if (invalid || swipeRefresh.isRefreshing) return
  368. PopupMenu(root.context, profileMenu).apply {
  369. setOnMenuItemClickListener(::onMenuItemClicked)
  370. populatePopupWithProfileActions(this, profile)
  371. show()
  372. }
  373. }
  374. private fun onMenuItemClicked(item: MenuItem): Boolean =
  375. when (item.itemId) {
  376. R.id.enable -> {
  377. if (canEnable) {
  378. enableOrDisableProfile(profile.iccid, true)
  379. } else {
  380. val resId = R.string.toast_profile_enable_cross_class
  381. Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
  382. .show()
  383. }
  384. true
  385. }
  386. R.id.disable -> {
  387. enableOrDisableProfile(profile.iccid, false)
  388. true
  389. }
  390. R.id.rename -> {
  391. ProfileRenameFragment.newInstance(
  392. slotId,
  393. portId,
  394. seId,
  395. profile.iccid,
  396. profile.displayName
  397. )
  398. .show(childFragmentManager, ProfileRenameFragment.TAG)
  399. true
  400. }
  401. R.id.delete -> {
  402. ProfileDeleteFragment.newInstance(
  403. slotId,
  404. portId,
  405. seId,
  406. profile.iccid,
  407. profile.displayName
  408. )
  409. .show(childFragmentManager, ProfileDeleteFragment.TAG)
  410. true
  411. }
  412. else -> false
  413. }
  414. }
  415. inner class EuiccProfileAdapter : RecyclerView.Adapter<ViewHolder>() {
  416. var profiles: List<LocalProfileInfo> = listOf()
  417. var footerViews: List<View> = listOf()
  418. override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
  419. when (ViewHolder.Type.fromInt(viewType)) {
  420. ViewHolder.Type.PROFILE -> {
  421. val view = LayoutInflater.from(parent.context)
  422. .inflate(R.layout.euicc_profile, parent, false)
  423. ProfileViewHolder(view)
  424. }
  425. ViewHolder.Type.FOOTER -> {
  426. FooterViewHolder()
  427. }
  428. }
  429. override fun getItemViewType(position: Int): Int =
  430. when {
  431. position < profiles.size -> {
  432. ViewHolder.Type.PROFILE.value
  433. }
  434. position >= profiles.size && position < profiles.size + footerViews.size -> {
  435. ViewHolder.Type.FOOTER.value
  436. }
  437. else -> -1
  438. }
  439. override fun onBindViewHolder(holder: ViewHolder, position: Int) {
  440. when (holder) {
  441. is ProfileViewHolder -> {
  442. holder.setProfile(profiles[position])
  443. holder.setEnabledProfile(profiles.enabled)
  444. holder.setProfileSequenceNumber(position + 1)
  445. }
  446. is FooterViewHolder -> {
  447. holder.attach(footerViews[position - profiles.size])
  448. }
  449. }
  450. }
  451. override fun onViewRecycled(holder: ViewHolder) {
  452. if (holder is FooterViewHolder) {
  453. holder.detach()
  454. }
  455. }
  456. override fun getItemCount(): Int = profiles.size + footerViews.size
  457. }
  458. }