EuiccManagementFragment.kt 19 KB

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