feat: Notification handling [1/2]

This commit is contained in:
Peter Cai 2023-12-31 08:53:37 -05:00
parent a4aaa9bb1a
commit 608dadd65d
19 changed files with 531 additions and 1 deletions

View file

@ -12,6 +12,10 @@
android:name="im.angry.openeuicc.ui.SettingsActivity"
android:label="@string/pref_settings" />
<activity
android:name="im.angry.openeuicc.ui.NotificationsActivity"
android:label="@string/profile_notifications" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"

View file

@ -1,10 +1,13 @@
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
@ -41,6 +44,11 @@ open class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfi
private val adapter = EuiccProfileAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -77,6 +85,23 @@ open class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfi
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")

View file

@ -0,0 +1,215 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.Html
import android.view.ContextMenu
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.MenuItem.OnMenuItemClickListener
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.forEach
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.displayName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileNotification
class NotificationsActivity: AppCompatActivity() {
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter()
private lateinit var euiccChannel: EuiccChannel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_notifications)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
euiccChannel = (application as OpenEuiccApplication).euiccChannelManager
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
swipeRefresh = findViewById(R.id.swipe_refresh)
notificationList = findViewById(R.id.recycler_view)
notificationList.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList)
swipeRefresh.setOnRefreshListener {
refresh()
}
refresh()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.activity_notifications, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
android.R.id.home -> {
finish()
true
}
R.id.help -> {
AlertDialog.Builder(this, R.style.AlertDialogTheme).apply {
setMessage(R.string.profile_notifications_help)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
show()
}
true
}
else -> super.onOptionsItemSelected(item)
}
private fun launchTask(task: suspend () -> Unit) {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
task()
swipeRefresh.isRefreshing = false
}
}
private fun refresh() {
launchTask {
val profiles = withContext(Dispatchers.IO) {
euiccChannel.lpa.profiles
}
notificationAdapter.notifications =
withContext(Dispatchers.IO) {
euiccChannel.lpa.notifications.map {
val profile = profiles.find { p -> p.iccid == it.iccid }
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
}
}
}
}
data class LocalProfileNotificationWrapper(
val inner: LocalProfileNotification,
val profileName: String
)
@SuppressLint("ClickableViewAccessibility")
inner class NotificationViewHolder(private val root: View):
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
private val address: TextView = root.findViewById(R.id.notification_address)
private val profileName: TextView = root.findViewById(R.id.notification_profile_name)
private lateinit var notification: LocalProfileNotificationWrapper
private var lastTouchX = 0f
private var lastTouchY = 0f
init {
root.isClickable = true
root.setOnCreateContextMenuListener(this)
root.setOnTouchListener { _, event ->
lastTouchX = event.x
lastTouchY = event.y
false
}
root.setOnLongClickListener {
root.showContextMenu(lastTouchX, lastTouchY)
true
}
}
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
root.context.getText(
when (operation) {
LocalProfileNotification.Operation.Install -> R.string.profile_notification_operation_download
LocalProfileNotification.Operation.Delete -> R.string.profile_notification_operation_delete
LocalProfileNotification.Operation.Enable -> R.string.profile_notification_operation_enable
LocalProfileNotification.Operation.Disable -> R.string.profile_notification_operation_disable
})
fun updateNotification(value: LocalProfileNotificationWrapper) {
notification = value
address.text = value.inner.notificationAddress
profileName.text = Html.fromHtml(
root.context.getString(R.string.profile_notification_name_format,
operationToLocalizedText(value.inner.profileManagementOperation),
value.profileName, value.inner.iccid),
Html.FROM_HTML_MODE_COMPACT)
}
override fun onCreateContextMenu(
menu: ContextMenu?,
v: View?,
menuInfo: ContextMenu.ContextMenuInfo?
) {
menuInflater.inflate(R.menu.notification_options, menu)
menu!!.forEach {
it.setOnMenuItemClickListener(this)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean =
when (item.itemId) {
R.id.notification_process -> {
launchTask {
euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
}
refresh()
true
}
R.id.notification_delete -> {
launchTask {
euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
}
refresh()
true
}
else -> false
}
}
inner class NotificationAdapter: RecyclerView.Adapter<NotificationViewHolder>() {
var notifications: List<LocalProfileNotificationWrapper> = listOf()
@SuppressLint("NotifyDataSetChanged")
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
val root = LayoutInflater.from(parent.context)
.inflate(R.layout.notification_item, parent, false)
return NotificationViewHolder(root)
}
override fun getItemCount(): Int = notifications.size
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) =
holder.updateNotification(notifications[position])
}
}

View file

@ -0,0 +1,21 @@
package im.angry.openeuicc.ui.preference
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder
@Suppress("unused")
class LongSummaryPreferenceCategory: PreferenceCategory {
constructor(ctx: Context): super(ctx)
constructor(ctx: Context, attrs: AttributeSet): super(ctx, attrs)
constructor(ctx: Context, attrs: AttributeSet, defStyle: Int): super(ctx, attrs, defStyle)
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val summaryText = holder.findViewById(android.R.id.summary) as TextView
summaryText.isSingleLine = false
summaryText.maxLines = 10
}
}

View file

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"/>
</vector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/notification_address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/notification_profile_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="12dp"
android:maxLines="1"
android:ellipsize="marquee"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/notification_address"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/help"
android:icon="@drawable/ic_help_black"
android:title="@string/help"
app:showAsAction="always" />
</menu>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/show_notifications"
android:title="@string/profile_notifications_show"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/notification_process"
android:title="@string/profile_notification_process" />
<item
android:id="@+id/notification_delete"
android:title="@string/profile_notification_delete" />
</menu>

View file

@ -2,6 +2,7 @@
<resources>
<string name="no_euicc">No eUICC card on this device is accessible by this app.\nInsert a supported eUICC card, or try out the privileged OpenEUICC app instead.</string>
<string name="unknown">Unknown</string>
<string name="help">Help</string>
<string name="channel_name_format">Logical Slot %d</string>
@ -33,7 +34,20 @@
<string name="profile_delete_confirm">Are you sure you want to delete the profile %s? This operation is irreversible.</string>
<string name="profile_notifications">Profile Notifications</string>
<string name="profile_notifications_show">Manage Notifications</string>
<string name="profile_notifications_help">eSIM profiles can send notifications to the carrier when they are downloaded, deleted, enabled, or disabled. The queue of these notifications to be sent is listed here.\n\nIn Settings, you can specify whether to send each type of notification automatically. Note that even if a notification has been sent, it will not be deleted automatically from the record, unless the queue runs out of space.\n\nHere, you can manually send or delete each pending notification.</string>
<string name="profile_notification_operation_download">Downloaded</string>
<string name="profile_notification_operation_delete">Deleted</string>
<string name="profile_notification_operation_enable">Enabled</string>
<string name="profile_notification_operation_disable">Disabled</string>
<string name="profile_notification_name_format">&lt;b&gt;%s&lt;/b&gt; %s (%s)</string>
<string name="profile_notification_process">Process</string>
<string name="profile_notification_delete">Delete</string>
<string name="pref_settings">Settings</string>
<string name="pref_notifications">Notifications</string>
<string name="pref_notifications_desc">eSIM profile operations send notifications to the carrier. Fine-tune this behavior as needed here.</string>
<string name="pref_info">Info</string>
<string name="pref_info_app_version">App Version</string>
<string name="pref_info_source_code">Source Code</string>

View file

@ -1,12 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<im.angry.openeuicc.ui.preference.LongSummaryPreferenceCategory
app:title="@string/pref_notifications"
app:summary="@string/pref_notifications_desc"
app:iconSpaceReserved="false">
</im.angry.openeuicc.ui.preference.LongSummaryPreferenceCategory>
<PreferenceCategory
app:title="@string/pref_info">
app:title="@string/pref_info"
app:iconSpaceReserved="false">
<Preference
app:iconSpaceReserved="false"
app:title="@string/pref_info_app_version"
app:key="pref_info_app_version" />
<Preference
app:iconSpaceReserved="false"
app:title="@string/pref_info_source_code"
app:summary="@string/pref_info_source_code_url"
app:key="pref_info_source_code"/>

View file

@ -2,6 +2,7 @@ package net.typeblog.lpac_jni
interface LocalProfileAssistant {
val profiles: List<LocalProfileInfo>
val notifications: List<LocalProfileNotification>
val eID: String
// Extended EuiccInfo for use with LUIs, containing information such as firmware version
val euiccInfo2: EuiccInfo2?
@ -13,6 +14,12 @@ interface LocalProfileAssistant {
fun downloadProfile(smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback): Boolean
fun deleteNotification(seqNumber: Long): Boolean
fun handleNotification(seqNumber: Long): Boolean
// Handle the latest entry of a particular type of notification
// Note that this is not guaranteed to always be reliable and no feedback will be provided on errors.
fun handleLatestNotification(operation: LocalProfileNotification.Operation)
fun setNickname(
iccid: String, nickname: String
): Boolean

View file

@ -0,0 +1,27 @@
package net.typeblog.lpac_jni
data class LocalProfileNotification(
val seqNumber: Long,
val profileManagementOperation: Operation,
val notificationAddress: String,
val iccid: String,
) {
enum class Operation {
Install,
Enable,
Disable,
Delete;
companion object {
@JvmStatic
fun fromString(str: String?) =
when (str?.lowercase()) {
"install" -> Install
"enable" -> Enable
"disable" -> Disable
"delete" -> Delete
else -> throw IllegalArgumentException("Unknown operation $str")
}
}
}
}

View file

@ -21,10 +21,15 @@ internal object LpacJni {
external fun es10cDeleteProfile(handle: Long, iccid: String): Int
external fun es10cSetNickname(handle: Long, iccid: String, nick: String): Int
// es10b
external fun es10bListNotification(handle: Long): Array<LocalProfileNotification>?
external fun es10bDeleteNotification(handle: Long, seqNumber: Long): Int
// es9p + es10b
// We do not expose all of the functions because of tediousness :)
external fun downloadProfile(handle: Long, smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback): Int
external fun handleNotification(handle: Long, seqNumber: Long): Int
// es10cex (actually part of es10b)
external fun es10cexGetEuiccInfo2(handle: Long): EuiccInfo2?

View file

@ -6,6 +6,7 @@ import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.HttpInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.ProfileDownloadCallback
class LocalProfileAssistantImpl(
@ -22,6 +23,11 @@ class LocalProfileAssistantImpl(
override val profiles: List<LocalProfileInfo>
get() = LpacJni.es10cGetProfilesInfo(contextHandle)!!.asList()
override val notifications: List<LocalProfileNotification>
get() =
(LpacJni.es10bListNotification(contextHandle) ?: arrayOf())
.sortedBy { it.seqNumber }.reversed()
override val eID: String
get() = LpacJni.es10cGetEid(contextHandle)!!
@ -45,6 +51,18 @@ class LocalProfileAssistantImpl(
return LpacJni.downloadProfile(contextHandle, smdp, matchingId, imei, confirmationCode, callback) == 0
}
override fun deleteNotification(seqNumber: Long): Boolean =
LpacJni.es10bDeleteNotification(contextHandle, seqNumber) == 0
override fun handleNotification(seqNumber: Long): Boolean =
LpacJni.handleNotification(contextHandle, seqNumber) == 0
override fun handleLatestNotification(operation: LocalProfileNotification.Operation) {
notifications.find { it.profileManagementOperation == operation }?.let {
handleNotification(it.seqNumber)
}
}
override fun setNickname(iccid: String, nickname: String): Boolean {
return LpacJni.es10cSetNickname(contextHandle, iccid, nickname) == 0
}

View file

@ -5,6 +5,7 @@
#include <syslog.h>
#include "lpac-jni.h"
#include "lpac-download.h"
#include "lpac-notifications.h"
#include "interface-wrapper.h"
JavaVM *jvm = NULL;
@ -30,6 +31,7 @@ jint JNI_OnLoad(JavaVM *vm, void *reserved) {
jvm = vm;
interface_wrapper_init();
lpac_download_init();
lpac_notifications_init();
LPAC_JNI_SETUP_ENV;
string_class = (*env)->FindClass(env, "java/lang/String");

View file

@ -0,0 +1,95 @@
#include "lpac-notifications.h"
#include <euicc/es9p.h>
#include <euicc/es10b.h>
#include <malloc.h>
jclass local_profile_notification_class;
jmethodID local_profile_notification_constructor;
jclass local_profile_notification_operation_class;
jmethodID local_profile_notification_operation_from_string;
void lpac_notifications_init() {
LPAC_JNI_SETUP_ENV;
local_profile_notification_class =
(*env)->FindClass(env, "net/typeblog/lpac_jni/LocalProfileNotification");
local_profile_notification_class =
(*env)->NewGlobalRef(env, local_profile_notification_class);
local_profile_notification_constructor =
(*env)->GetMethodID(env, local_profile_notification_class, "<init>",
"(JLnet/typeblog/lpac_jni/LocalProfileNotification$Operation;Ljava/lang/String;Ljava/lang/String;)V");
local_profile_notification_operation_class =
(*env)->FindClass(env, "net/typeblog/lpac_jni/LocalProfileNotification$Operation");
local_profile_notification_operation_class =
(*env)->NewGlobalRef(env, local_profile_notification_operation_class);
local_profile_notification_operation_from_string =
(*env)->GetStaticMethodID(env, local_profile_notification_operation_class, "fromString",
"(Ljava/lang/String;)Lnet/typeblog/lpac_jni/LocalProfileNotification$Operation;");
}
JNIEXPORT jobject JNICALL
Java_net_typeblog_lpac_1jni_LpacJni_es10bListNotification(JNIEnv *env, jobject thiz, jlong handle) {
struct euicc_ctx *ctx = (struct euicc_ctx *) handle;
struct es10b_notification_metadata *info;
jobject notification = NULL;
jobject operation = NULL;
jobjectArray ret = NULL;
int count;
if (es10b_list_notification(ctx, &info, &count) < 0)
return NULL;
ret = (*env)->NewObjectArray(env, count, local_profile_notification_class, NULL);
for (int i = 0; i < count; i++) {
operation =
(*env)->CallStaticObjectMethod(env, local_profile_notification_operation_class,
local_profile_notification_operation_from_string,
toJString(env, info[i].profileManagementOperation));
notification =
(*env)->NewObject(env, local_profile_notification_class,
local_profile_notification_constructor, info[i].seqNumber, operation,
toJString(env, info[i].notificationAddress),
toJString(env, info[i].iccid));
(*env)->SetObjectArrayElement(env, ret, i, notification);
(*env)->DeleteLocalRef(env, operation);
(*env)->DeleteLocalRef(env, notification);
}
es10b_notification_metadata_free_all(info, count);
return ret;
}
JNIEXPORT jint JNICALL
Java_net_typeblog_lpac_1jni_LpacJni_handleNotification(JNIEnv *env, jobject thiz, jlong handle,
jlong seq_number) {
struct euicc_ctx *ctx = (struct euicc_ctx *) handle;
char *b64_payload = NULL;
char *receiver = NULL;
int res;
res = es10b_retrieve_notification(ctx, &b64_payload, &receiver, (unsigned long) seq_number);
if (res < 0)
goto out;
res = es9p_handle_notification(ctx, receiver, b64_payload);
if (res < 0)
goto out;
out:
free(b64_payload);
free(receiver);
return res;
}
JNIEXPORT jint JNICALL
Java_net_typeblog_lpac_1jni_LpacJni_es10bDeleteNotification(JNIEnv *env, jobject thiz, jlong handle,
jlong seq_number) {
struct euicc_ctx *ctx = (struct euicc_ctx *) handle;
return es10b_remove_notification_from_list(ctx, (unsigned long) seq_number);
}

View file

@ -0,0 +1,6 @@
#pragma once
#include <jni.h>
#include "lpac-jni.h"
void lpac_notifications_init();