EuiccManagementFragment.kt 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. package im.angry.openeuicc.ui
  2. import android.annotation.SuppressLint
  3. import android.content.Intent
  4. import android.os.Bundle
  5. import android.text.method.PasswordTransformationMethod
  6. import android.util.Log
  7. import android.view.LayoutInflater
  8. import android.view.Menu
  9. import android.view.MenuInflater
  10. import android.view.MenuItem
  11. import android.view.View
  12. import android.view.ViewGroup
  13. import android.widget.FrameLayout
  14. import android.widget.ImageButton
  15. import android.widget.PopupMenu
  16. import android.widget.TextView
  17. import android.widget.Toast
  18. import androidx.appcompat.app.AlertDialog
  19. import androidx.fragment.app.Fragment
  20. import androidx.lifecycle.lifecycleScope
  21. import androidx.recyclerview.widget.LinearLayoutManager
  22. import androidx.recyclerview.widget.RecyclerView
  23. import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
  24. import com.google.android.material.floatingactionbutton.FloatingActionButton
  25. import net.typeblog.lpac_jni.LocalProfileInfo
  26. import im.angry.openeuicc.common.R
  27. import im.angry.openeuicc.core.EuiccChannelManager
  28. import im.angry.openeuicc.util.*
  29. import kotlinx.coroutines.Dispatchers
  30. import kotlinx.coroutines.TimeoutCancellationException
  31. import kotlinx.coroutines.flow.first
  32. import kotlinx.coroutines.launch
  33. import kotlinx.coroutines.withContext
  34. open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
  35. EuiccChannelFragmentMarker {
  36. companion object {
  37. const val TAG = "EuiccManagementFragment"
  38. fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
  39. newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId)
  40. }
  41. private lateinit var swipeRefresh: SwipeRefreshLayout
  42. private lateinit var fab: FloatingActionButton
  43. private lateinit var profileList: RecyclerView
  44. private val adapter = EuiccProfileAdapter()
  45. // Marker for when this fragment might enter an invalid state
  46. // e.g. after a failed enable / disable operation
  47. private var invalid = false
  48. override fun onCreate(savedInstanceState: Bundle?) {
  49. super.onCreate(savedInstanceState)
  50. setHasOptionsMenu(true)
  51. }
  52. override fun onCreateView(
  53. inflater: LayoutInflater,
  54. container: ViewGroup?,
  55. savedInstanceState: Bundle?
  56. ): View {
  57. val view = inflater.inflate(R.layout.fragment_euicc, container, false)
  58. swipeRefresh = view.requireViewById(R.id.swipe_refresh)
  59. fab = view.requireViewById(R.id.fab)
  60. profileList = view.requireViewById(R.id.profile_list)
  61. return view
  62. }
  63. override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  64. super.onViewCreated(view, savedInstanceState)
  65. swipeRefresh.setOnRefreshListener { refresh() }
  66. profileList.adapter = adapter
  67. profileList.layoutManager =
  68. LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
  69. fab.setOnClickListener {
  70. ProfileDownloadFragment.newInstance(slotId, portId)
  71. .show(childFragmentManager, ProfileDownloadFragment.TAG)
  72. }
  73. refresh()
  74. }
  75. override fun onEuiccProfilesChanged() {
  76. refresh()
  77. }
  78. override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  79. super.onCreateOptionsMenu(menu, inflater)
  80. inflater.inflate(R.menu.fragment_euicc, menu)
  81. }
  82. override fun onOptionsItemSelected(item: MenuItem): Boolean =
  83. when (item.itemId) {
  84. R.id.show_notifications -> {
  85. Intent(requireContext(), NotificationsActivity::class.java).apply {
  86. putExtra("logicalSlotId", channel.logicalSlotId)
  87. startActivity(this)
  88. }
  89. true
  90. }
  91. else -> super.onOptionsItemSelected(item)
  92. }
  93. protected open suspend fun onCreateFooterViews(parent: ViewGroup): List<View> = listOf()
  94. @SuppressLint("NotifyDataSetChanged")
  95. private fun refresh() {
  96. if (invalid) return
  97. swipeRefresh.isRefreshing = true
  98. lifecycleScope.launch {
  99. val profiles = withContext(Dispatchers.IO) {
  100. euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
  101. channel.lpa.profiles
  102. }
  103. withContext(Dispatchers.Main) {
  104. adapter.profiles = profiles.operational
  105. adapter.footerViews = onCreateFooterViews(profileList)
  106. adapter.notifyDataSetChanged()
  107. swipeRefresh.isRefreshing = false
  108. }
  109. }
  110. }
  111. private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
  112. swipeRefresh.isRefreshing = true
  113. fab.isEnabled = false
  114. lifecycleScope.launch {
  115. beginTrackedOperation {
  116. val (res, refreshed) =
  117. if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
  118. // Sometimes, we *can* enable or disable the profile, but we cannot
  119. // send the refresh command to the modem because the profile somehow
  120. // makes the modem "busy". In this case, we can still switch by setting
  121. // refresh to false, but then the switch cannot take effect until the
  122. // user resets the modem manually by toggling airplane mode or rebooting.
  123. Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
  124. } else {
  125. Pair(true, true)
  126. }
  127. if (!res) {
  128. Log.d(TAG, "Failed to enable / disable profile $iccid")
  129. withContext(Dispatchers.Main) {
  130. Toast.makeText(
  131. context,
  132. R.string.toast_profile_enable_failed,
  133. Toast.LENGTH_LONG
  134. ).show()
  135. }
  136. return@beginTrackedOperation false
  137. }
  138. if (!refreshed && channel.slotId != EuiccChannelManager.USB_CHANNEL_ID) {
  139. withContext(Dispatchers.Main) {
  140. AlertDialog.Builder(requireContext()).apply {
  141. setMessage(R.string.switch_did_not_refresh)
  142. setPositiveButton(android.R.string.ok) { dialog, _ ->
  143. dialog.dismiss()
  144. requireActivity().finish()
  145. }
  146. setOnDismissListener { _ ->
  147. requireActivity().finish()
  148. }
  149. show()
  150. }
  151. }
  152. return@beginTrackedOperation true
  153. }
  154. if (channel.slotId != EuiccChannelManager.USB_CHANNEL_ID) {
  155. try {
  156. euiccChannelManager.waitForReconnect(
  157. slotId,
  158. portId,
  159. timeoutMillis = 30 * 1000
  160. )
  161. } catch (e: TimeoutCancellationException) {
  162. withContext(Dispatchers.Main) {
  163. // Prevent this Fragment from being used again
  164. invalid = true
  165. // Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
  166. AlertDialog.Builder(requireContext()).apply {
  167. setMessage(R.string.enable_disable_timeout)
  168. setPositiveButton(android.R.string.ok) { dialog, _ ->
  169. dialog.dismiss()
  170. requireActivity().finish()
  171. }
  172. setOnDismissListener { _ ->
  173. requireActivity().finish()
  174. }
  175. show()
  176. }
  177. }
  178. return@beginTrackedOperation false
  179. }
  180. }
  181. preferenceRepository.notificationSwitchFlow.first()
  182. }
  183. refresh()
  184. fab.isEnabled = true
  185. }
  186. }
  187. protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
  188. popup.inflate(R.menu.profile_options)
  189. if (profile.isEnabled) {
  190. popup.menu.findItem(R.id.enable).isVisible = false
  191. popup.menu.findItem(R.id.delete).isVisible = false
  192. }
  193. }
  194. sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
  195. enum class Type(val value: Int) {
  196. PROFILE(0),
  197. FOOTER(1);
  198. companion object {
  199. fun fromInt(value: Int) =
  200. Type.values().first { it.value == value }
  201. }
  202. }
  203. }
  204. inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
  205. fun attach(view: View) {
  206. view.parent?.let { (it as ViewGroup).removeView(view) }
  207. (itemView as FrameLayout).addView(view)
  208. }
  209. fun detach() {
  210. (itemView as FrameLayout).removeAllViews()
  211. }
  212. }
  213. inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
  214. private val iccid: TextView = root.requireViewById(R.id.iccid)
  215. private val name: TextView = root.requireViewById(R.id.name)
  216. private val state: TextView = root.requireViewById(R.id.state)
  217. private val provider: TextView = root.requireViewById(R.id.provider)
  218. private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
  219. init {
  220. iccid.setOnClickListener {
  221. if (iccid.transformationMethod == null) {
  222. iccid.transformationMethod = PasswordTransformationMethod.getInstance()
  223. } else {
  224. iccid.transformationMethod = null
  225. }
  226. }
  227. profileMenu.setOnClickListener { showOptionsMenu() }
  228. }
  229. private lateinit var profile: LocalProfileInfo
  230. fun setProfile(profile: LocalProfileInfo) {
  231. this.profile = profile
  232. name.text = profile.displayName
  233. state.setText(
  234. if (profile.isEnabled) {
  235. R.string.enabled
  236. } else {
  237. R.string.disabled
  238. }
  239. )
  240. provider.text = profile.providerName
  241. iccid.text = profile.iccid
  242. iccid.transformationMethod = PasswordTransformationMethod.getInstance()
  243. }
  244. private fun showOptionsMenu() {
  245. // Prevent users from doing multiple things at once
  246. if (invalid || swipeRefresh.isRefreshing) return
  247. PopupMenu(root.context, profileMenu).apply {
  248. setOnMenuItemClickListener(::onMenuItemClicked)
  249. populatePopupWithProfileActions(this, profile)
  250. show()
  251. }
  252. }
  253. private fun onMenuItemClicked(item: MenuItem): Boolean =
  254. when (item.itemId) {
  255. R.id.enable -> {
  256. enableOrDisableProfile(profile.iccid, true)
  257. true
  258. }
  259. R.id.disable -> {
  260. enableOrDisableProfile(profile.iccid, false)
  261. true
  262. }
  263. R.id.rename -> {
  264. ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
  265. .show(childFragmentManager, ProfileRenameFragment.TAG)
  266. true
  267. }
  268. R.id.delete -> {
  269. ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
  270. .show(childFragmentManager, ProfileDeleteFragment.TAG)
  271. true
  272. }
  273. else -> false
  274. }
  275. }
  276. inner class EuiccProfileAdapter : RecyclerView.Adapter<ViewHolder>() {
  277. var profiles: List<LocalProfileInfo> = listOf()
  278. var footerViews: List<View> = listOf()
  279. override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
  280. when (ViewHolder.Type.fromInt(viewType)) {
  281. ViewHolder.Type.PROFILE -> {
  282. val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
  283. ProfileViewHolder(view)
  284. }
  285. ViewHolder.Type.FOOTER -> {
  286. FooterViewHolder()
  287. }
  288. }
  289. override fun getItemViewType(position: Int): Int =
  290. when {
  291. position < profiles.size -> {
  292. ViewHolder.Type.PROFILE.value
  293. }
  294. position >= profiles.size && position < profiles.size + footerViews.size -> {
  295. ViewHolder.Type.FOOTER.value
  296. }
  297. else -> -1
  298. }
  299. override fun onBindViewHolder(holder: ViewHolder, position: Int) {
  300. when (holder) {
  301. is ProfileViewHolder -> {
  302. holder.setProfile(profiles[position])
  303. }
  304. is FooterViewHolder -> {
  305. holder.attach(footerViews[position - profiles.size])
  306. }
  307. }
  308. }
  309. override fun onViewRecycled(holder: ViewHolder) {
  310. if (holder is FooterViewHolder) {
  311. holder.detach()
  312. }
  313. }
  314. override fun getItemCount(): Int = profiles.size + footerViews.size
  315. }
  316. }