OpenEUICC/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt
Peter Cai e48f9aa828
All checks were successful
/ build-debug (push) Successful in 4m51s
refactor: Channel validity, and reconnection
* ApduInterfaces also need a concept of validity based on the underlying
  APDU channel. For example, OMAPI depends on SEService being still
  connected.
* We then rely on this validity to wait for reconnection; we do not need
  to manually remove all channels under a slot because the rest will be
  invalid anyway, and the next attempt at connection will lazily
  recreate the channel.
* We had to manage channels manually before during reconnect because
  `valid` may result in SIGSEGV's when the underlying APDU channel has
  become invalid. This is avoided by the validity concept added to APDU
  channels.
2024-03-22 21:08:59 -04:00

324 lines
12 KiB
Kotlin

package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.text.method.PasswordTransformationMethod
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
EuiccChannelFragmentMarker {
companion object {
const val TAG = "EuiccManagementFragment"
fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId)
}
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var fab: FloatingActionButton
private lateinit var profileList: RecyclerView
private val adapter = EuiccProfileAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.fragment_euicc, container, false)
swipeRefresh = view.requireViewById(R.id.swipe_refresh)
fab = view.requireViewById(R.id.fab)
profileList = view.requireViewById(R.id.profile_list)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
swipeRefresh.setOnRefreshListener { refresh() }
profileList.adapter = adapter
profileList.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
ProfileDownloadFragment.newInstance(slotId, portId)
.show(childFragmentManager, ProfileDownloadFragment.TAG)
}
}
override fun onStart() {
super.onStart()
refresh()
}
override fun onEuiccProfilesChanged() {
refresh()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_euicc, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.show_notifications -> {
Intent(requireContext(), NotificationsActivity::class.java).apply {
putExtra("logicalSlotId", channel.logicalSlotId)
startActivity(this)
}
true
}
else -> super.onOptionsItemSelected(item)
}
protected open suspend fun onCreateFooterViews(parent: ViewGroup): List<View> = listOf()
@SuppressLint("NotifyDataSetChanged")
private fun refresh() {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
val profiles = withContext(Dispatchers.IO) {
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
channel.lpa.profiles
}
withContext(Dispatchers.Main) {
adapter.profiles = profiles.operational
adapter.footerViews = onCreateFooterViews(profileList)
adapter.notifyDataSetChanged()
swipeRefresh.isRefreshing = false
}
}
}
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
swipeRefresh.isRefreshing = true
fab.isEnabled = false
lifecycleScope.launch {
beginTrackedOperation {
val res = if (enable) {
channel.lpa.enableProfile(iccid)
} else {
channel.lpa.disableProfile(iccid)
}
if (!res) {
Log.d(TAG, "Failed to enable / disable profile $iccid")
Toast.makeText(context, R.string.toast_profile_enable_failed, Toast.LENGTH_LONG)
.show()
return@beginTrackedOperation false
}
try {
euiccChannelManager.waitForReconnect(slotId, portId, timeoutMillis = 30 * 1000)
} catch (e: TimeoutCancellationException) {
withContext(Dispatchers.Main) {
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.enable_disable_timeout)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
}
setOnDismissListener { _ ->
requireActivity().finish()
}
show()
}
}
return@beginTrackedOperation false
}
if (enable) {
preferenceRepository.notificationEnableFlow.first()
} else {
preferenceRepository.notificationDisableFlow.first()
}
}
refresh()
fab.isEnabled = true
}
}
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
popup.inflate(R.menu.profile_options)
if (profile.isEnabled) {
popup.menu.findItem(R.id.enable).isVisible = false
popup.menu.findItem(R.id.delete).isVisible = false
}
}
sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
enum class Type(val value: Int) {
PROFILE(0),
FOOTER(1);
companion object {
fun fromInt(value: Int) =
Type.values().first { it.value == value }
}
}
}
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
fun attach(view: View) {
view.parent?.let { (it as ViewGroup).removeView(view) }
(itemView as FrameLayout).addView(view)
}
fun detach() {
(itemView as FrameLayout).removeAllViews()
}
}
inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
private val iccid: TextView = root.requireViewById(R.id.iccid)
private val name: TextView = root.requireViewById(R.id.name)
private val state: TextView = root.requireViewById(R.id.state)
private val provider: TextView = root.requireViewById(R.id.provider)
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
init {
iccid.setOnClickListener {
if (iccid.transformationMethod == null) {
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
} else {
iccid.transformationMethod = null
}
}
profileMenu.setOnClickListener { showOptionsMenu() }
}
private lateinit var profile: LocalProfileInfo
fun setProfile(profile: LocalProfileInfo) {
this.profile = profile
name.text = profile.displayName
state.setText(
if (profile.isEnabled) {
R.string.enabled
} else {
R.string.disabled
}
)
provider.text = profile.providerName
iccid.text = profile.iccid
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
}
private fun showOptionsMenu() {
PopupMenu(root.context, profileMenu).apply {
setOnMenuItemClickListener(::onMenuItemClicked)
populatePopupWithProfileActions(this, profile)
show()
}
}
private fun onMenuItemClicked(item: MenuItem): Boolean =
when (item.itemId) {
R.id.enable -> {
enableOrDisableProfile(profile.iccid, true)
true
}
R.id.disable -> {
enableOrDisableProfile(profile.iccid, false)
true
}
R.id.rename -> {
ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
.show(childFragmentManager, ProfileRenameFragment.TAG)
true
}
R.id.delete -> {
ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
.show(childFragmentManager, ProfileDeleteFragment.TAG)
true
}
else -> false
}
}
inner class EuiccProfileAdapter : RecyclerView.Adapter<ViewHolder>() {
var profiles: List<LocalProfileInfo> = listOf()
var footerViews: List<View> = listOf()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
when (ViewHolder.Type.fromInt(viewType)) {
ViewHolder.Type.PROFILE -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
ProfileViewHolder(view)
}
ViewHolder.Type.FOOTER -> {
FooterViewHolder()
}
}
override fun getItemViewType(position: Int): Int =
when {
position < profiles.size -> {
ViewHolder.Type.PROFILE.value
}
position >= profiles.size && position < profiles.size + footerViews.size -> {
ViewHolder.Type.FOOTER.value
}
else -> -1
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
when (holder) {
is ProfileViewHolder -> {
holder.setProfile(profiles[position])
}
is FooterViewHolder -> {
holder.attach(footerViews[position - profiles.size])
}
}
}
override fun onViewRecycled(holder: ViewHolder) {
if (holder is FooterViewHolder) {
holder.detach()
}
}
override fun getItemCount(): Int = profiles.size + footerViews.size
}
}