EuiccManagementFragment.kt 17 KB

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