Skip to main content

2 posts tagged with "Android"

Posts related to Android development

View All Tags

Deep dive into the secrets of Compose

· 5 min read

Jetpack Compose is the modern technology built upon the solid foundation of Kotlin, which enables us to express the UI in a declarative way. The compose compiler abstracts and keeps us away from the heavy and complicated state management. In short, with Compose, we can express UI as a function of the underlying state and Compose compiler will do the rest for us.

The following questions came to my mind when I started learning Compose.

  1. How Compose do the state management under the hood?
  2. Why @Composable functions are not callable from outside of the compose world?
  3. How Compose remembers the state even if it has a functional approach?

The documentation somewhat answers these questions, however I took a reverse engineering approach to have a deep understanding of these points. For achieving that, I made a simple counter app in Compose.

@Composable
fun CounterApp() {
var count by remember {
mutableStateOf(0)
}
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = count.toString(),
fontSize = 56.sp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Row(modifier = Modifier.align(Alignment.CenterHorizontally)) {
Button(onClick = { count += 1 }) {
Text(text = "Increment")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { count -= 1 }) {
Text(text = "Decrement")
}
}
}
}

This is how the app looks like:

For the sake of simplicity, let's keep this functional model in the mind:

CounterApp():
Column(...)
Text(...)
Row(...)
Button(...)
Spacer(...)
Button(...)

Reverse Engineering Compose app

To decompile the app, I used dex2jar for decompiling APK file, and jd-gui for decompiling JAR to human-readable source.

user@host:~$ ./dex-tools-2.1/d2j-dex2jar.sh app-debug.apk 
dex2jar app-debug.apk -> ./app-debug-dex2jar.jar

user@host:~$ java -jar ./jd-gui-1.6.6-min.jar app-debug-dex2jar.jar

In the JD-GUI window, navigate to the MainActivityKt.class file and look for the CounterApp() method. There we found that strangers in the party! -- the Composer paramComposer and int paramInt.

Decompiled Java code in JD-GUI app

These parameters injected by the compose compiler needs more explaination. The compose framework encourages the developer to have functional approach to the UI, instead of the object oriented "View" like approach. But the functional approach have some drawbacks, in the context of building UI:

  • The functions are called exactly in the order in the call stack.
  • They are called on the same thread of the caller, unless caller spawns a new thread for executing them.
  • The executed functions will not be executed later, unless they are called again.

These limitations needs to be resolved to develop a UI framework with the functional approach. That's why the Compose compiler injects these parameters. The compose compiler wraps the @Composable tree within restart groups, replaceable groups, movable groups, determined reusable nodes, and add checks to detect change in state values (aka "remembered" values). Also, the composer will take a reference to the @Composable function before returning to call it again when the runtime detects state changes (changes to the "remembered" values). This is how the compose compiler organises the @Composable tree with restart groups and replaceable groups:

CounterApp(..., composer):
composer.startRestartGroup(...)
if changed or not composer.skipping:
mutableState = composer.getRememberedValue()
if mutableState is empty:
mutableState = composer.getSnapshot()
composer.updateRememberedValue(mutableState)
composer.startReplaceableGroup(...)
Column(..., composer)
composer.startReplaceableGroup(...)
Text(..., mutableState.value.toString(), composer)
Row(..., composer)
composer.startReplaceableGroup(...)
Button(..., composer)
Spacer(..., composer)
Button(..., composer)
composer.endReplaceableGroup(...)
composer.endReplaceableGroup(...)
composer.endReplaceableGroup(...)
composer.endRestartGroup(...)
updateScope(&CounterApp)

This answers question #2. In the compiled code, the @Composable functions require the instance of Composer interface. That's why it cannot be called from outside of the @Composable context.

Restart groups vs Replaceable groups

To put this in the most simplest manner, restart groups are a unit of composable chunk which will be called again (aka "recomposed") when the state (aka "remembered" value) changes. The replaceable groups are @Composable tree which is replaced with a new tree when the state changes, because the change of state results in changes to the existing nodes, new-born nodes and deleted nodes. The compose will keep the remembered values scoped to the restart groups internally. Also, it will identify the state changes, compute the changes in the @Composable tree, determine the affected and unaffected nodes, and replace/reuse the affected nodes in the most efficient manner.

Optimizations

The compose compiler optimizes the recomposition in the most efficient way. From the reverse engineered code, we can find the compose compiler applies several optimizations in the code. Here are a few examples:

The compiler add code for checking changes in state, and fetch the most up-to-date state values.

Code for checking change in state

Also, the compiler tries to reuse the existing nodes to achieve maximum performance.

Code for reusing the nodes if possible

The most notable fact is, the compose runtime will collect a callable reference to the function and keeps it internally. The compose runtime will call it regardless of the order, or from any thread, whenever a recomposition is required.

Code for obtaining reference to the @Composable function for recomposing

This is how the @Composable function is executed regardless of the order of call or the state of call-stack.

It is so wonderful to see how Compose adheres to the functional way of doing things, even keeping a clear separation between the declarative UI and state. The higher degree of code reusability, simplicity and the powerful optimizations and state management under the hood makes the Jetpack Compose a productive, next generation UI framework.

Android Signing Key Rotation – Explained

· 10 min read

This article explains how to safely rotate signing keys for Android apps, without losing trust to the previous keys. Basic knowledge in JDK and Android SDK command-line tools like adb, apksigner, keytool is required. Also, it would be nice if you have basic knowledge in command-line shells like bash or zsh. It is recommended to configure the $PATH variable of your default shell to have the installation directories of JDK and Android SDK command-line tools, so you can avoid typing the full path of the tool everytime.

Introduction

From the Android developer documentation:

Android 9 (API level 28) supports APK key rotation, which gives apps the ability to change their signing key as part of an APK update. To make rotation practical, APKs must indicate levels of trust between the new and old signing key. To support key rotation, we updated the APK signature scheme from v2 to v3 to allow the new and old keys to be used. V3 adds information about the supported SDK versions and a proof-of-rotation struct to the APK signing block.

Devices running Android 8.1 (API level 27) or lower don't support changing the signing certificate. If your app's minSdkVersion is 27 or lower, use an old signing certificate to sign your app in addition to the new signature.

This breaks down to these minority use cases:

  • You are targetting your app for Android 9 or later.
  • Your keystore is going to expire soon. Still you are supporting the project, and have to publish updates.
  • The project is handed over to a new company, and they have to use their key to sign the updates.

However, it doesn't mean to solve these situations:

  • Loss of keystore.
  • Signing apps which targets Android prior to 9 (Pie, API 28).

The introduction of key rotation have minor impact around the developers for now, and most of us don't really care about it. The cryptographic advancements over the APK siging schemes from v1 to v4 is appreciable. The APK signing scheme v2 detects the tampering of zip central directory, and also improve the speed of installation. V3 introduced key rotation and the ability to sign with multiple keystores. The preview V4 scheme produces a new kind of signature in a separate file (apk-name.apk.idsig). This scheme supports ADB incremental APK installation, which speeds up APK install.

Let's get started

For better understanding of how the key rotation works, here is an illustration. The relationship between the keys and signatures are expressed through lineages, which establishes the cryptographic trust between the existing key and the new key. If you have n number of keys, you will need n-1 lineages to maintain trust between keys.

Let's start with an unsigned build of the application app-release-unsigned.apk. First of all, let's create a keystore with keytool tool from JDK.

user@host:~$ keytool -genkeypair \
-alias first \
-keyalg RSA \
-keysize 4096 \
-validity 10000 \
-keypass ******** \
-keystore first.jks \
-storepass ******** \
-storetype PKCS12

What is your first and last name?
[Unknown]: First
What is the name of your organizational unit?
[Unknown]: Example
What is the name of your organization?
[Unknown]: Example
What is the name of your City or Locality?
[Unknown]: Example
What is the name of your State or Province?
[Unknown]: Example
What is the two-letter country code for this unit?
[Unknown]: IN
Is CN=First, OU=Example, O=Example, L=Example, ST=Example, C=IN correct?
[no]: yes

Next, sign your application with the keystore first.jks.

user@host:~$ apksigner sign \
--ks first.jks \
--in app-release-unsigned.apk \
--out app-first.apk
Keystore password for signer #1: ********

Now, you have your application signed with first.jks to app-first.jks. Generate your second keystore second.jks.

user@host:~$ keytool -genkeypair \
-alias second \
-keyalg RSA \
-keysize 4096 \
-validity 10000 \
-keypass ******** \
-keystore second.jks \
-storepass ******** \
-storetype PKCS12

This will ask the same questions as asked when you created the first key. After that, sign your app with second key to app-second.apk.

user@host:~$ apksigner sign \
--ks second.jks \
--in app-release-unsigned.apk \
--out app-second.apk
Keystore password for signer #1: ********

All right. Now, verify the both apps are signed with the correct keys.

user@host:~$ apksigner verify -v --print-certs app-first.apk 
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Number of signers: 1
Signer #1 certificate DN: CN=First, OU=Example, O=Example, L=Example, ST=Example, C=IN
Signer #1 certificate SHA-256 digest: b3f51e5541d0952099f14c2e8b0c1cdd7c50d4378f6ed5b29ad10613e86d6bfc
Signer #1 certificate SHA-1 digest: 583905f0bea5b7e33a14296d3dfed320706d37b3
Signer #1 certificate MD5 digest: fd7c9b3ae1c1b57aa8a4dd9a6e23f56b
Signer #1 key algorithm: RSA
Signer #1 key size (bits): 4096
Signer #1 public key SHA-256 digest: fb4a91fdaf390e50b023191c58983de2cf57423cf920d7d6132d16062793081f
Signer #1 public key SHA-1 digest: 4810c2293d6cdf901e422c70494c69b3fdad7902
Signer #1 public key MD5 digest: 314d879ff989fb8d2c7dbdde23b14e8c

If the command outputs Verifies, everything is good.

Let's verify the app-second.apk

user@host:~$ apksigner verify -v --print-certs app-second.apk
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Number of signers: 1
Signer #1 certificate DN: CN=Second, OU=Example, O=Example, L=Example, ST=Example, C=IN
Signer #1 certificate SHA-256 digest: 1c7642f167f13281932c4d6f0df654217117ede891e529dc4cecb590ec92e365
Signer #1 certificate SHA-1 digest: 6581fe770a6a676a4ae0a8af464465bc27d44e4a
Signer #1 certificate MD5 digest: 8845af2fad3b535727b1b0ed63b00e43
Signer #1 key algorithm: RSA
Signer #1 key size (bits): 4096
Signer #1 public key SHA-256 digest: b255b671617884a9ab827a782f70d04491333599af33dfd5449367df154e286a
Signer #1 public key SHA-1 digest: 3e1ed768068912ec5cdce22f9c2e3ac25dd2eab2
Signer #1 public key MD5 digest: 1af143237d97328108a49e3d84968628
.... (output redacted for brevity)

Just like the first one, if the command outputs Verifies, everything is good.

Let's try to install the signed apps.

user@host:~$ adb install app-first.apk 
Performing Streamed Install
Success

user@host:~$ adb install app-second.apk
Performing Streamed Install
adb: failed to install app-second.apk: Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package com.example.app signatures do not match previously installed version; ignoring!]

This error is expected. Both of the apps have same package identifiers and different signatures. Android forbids update if signature mismatches.

Android accepts the APK only if it proves the trust with older signing keys. APK Signature Scheme v3 introduces the facility to do this. With the apksigner utility, we have to rotate the key to create a signing certificate lineage and sign the APK. It includes the proof that the new key has a relationship to the old key.

user@host:~$ apksigner rotate \
--old-signer \
--ks first.jks \
--new-signer \
--ks second.jks \
--out lineage-1-2

Keystore password for old signer: ********
Keystore password for new signer: ********

Now we sign the APK with the new signer second.jks keystore, provided the old keystore first.jks and the signing certificate lineage of the keystores.

user@host:~$ apksigner sign \
--ks first.jks \
--next-signer --ks second.jks \
--lineage lineage-1-2 \
--in app-release-unsigned.apk \
--out app-rotated-1-2.apk

Keystore password for signer #1: ********
Keystore password for signer #2: ********

All right. Let's take a look on the signature of app-rotated-1-2.apk

user@host:~$ apksigner verify -v --print-certs app-rotated-1-2.apk
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Number of signers: 1
Signer #1 certificate DN: CN=Second, OU=Example, O=Example, L=Example, ST=Example, C=IN
Signer #1 certificate SHA-256 digest: 1c7642f167f13281932c4d6f0df654217117ede891e529dc4cecb590ec92e365
Signer #1 certificate SHA-1 digest: 6581fe770a6a676a4ae0a8af464465bc27d44e4a
Signer #1 certificate MD5 digest: 8845af2fad3b535727b1b0ed63b00e43
Signer #1 key algorithm: RSA
Signer #1 key size (bits): 4096
Signer #1 public key SHA-256 digest: b255b671617884a9ab827a782f70d04491333599af33dfd5449367df154e286a
Signer #1 public key SHA-1 digest: 3e1ed768068912ec5cdce22f9c2e3ac25dd2eab2
Signer #1 public key MD5 digest: 1af143237d97328108a49e3d84968628
.... (output redacted for brevity)

The signature looks exactly like from the app-second.apk. Let's install over the app-first.apk.

adb install app-rotated-1-2.apk 
Performing Streamed Install
Success

The rotated APK installs fine for the Androids before 9.0 Pie API 28. For previous Androids, -r flag is necessary. It tells the package manager to re-install the package. For Android Pie 9.0 and later, it is also possible to install app-second.apk after app-rotated-1-2.apk. This is because the app-rotated-1-2.apk proves the new signature belongs to the same developer, and further installations will requires signing from the new keystore only.

For the earlier versions of Android, the future updates will requires to be signed with the previous keystore and the signing certificate lineage, which invalidates the benefit of key rotation. I hope Google will find a solution for that.

Rotate it again

What will have to do if you want to publish updates with a newer keystore third.jks? Rotate it again, with older keystores along the previous signing certificate lineage.

user@host:~$ apksigner rotate \
--in lineage-1-2 \
--out lineage-1-2-3 \
--old-signer --ks second.jks \
--new-signer --ks third.jks

Keystore password for old signer: ********
Keystore password for new signer: ********

This will generate a new signing certificate lineage, which proves the third.jks keystore belongs to the family of first.jks and second.jks. Now sign with the latest keystore third.jks, previous keystore second.jks and the latest signing certificate lineage lineage-1-2-3.

user@host:~$ apksigner sign \
--ks first.jks \
--next-signer --ks second.jks \
--next-signer --ks third.jks \
--lineage lineage-1-2-3 \
--in app-release-unsigned.apk \
--out app-rotated-2-3.apk

Keystore password for signer #1: ********
Keystore password for signer #2: ********
Keystore password for signer #3: ********

The rotated APK will have signature by third.jks keystore, but includes the proof that it can be verified over app-first.apk and app-second.apk.

user@host:~$ apksigner verify -v --print-certs app-rotated-2-3.apk 
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Number of signers: 1
Signer #1 certificate DN: CN=Third, OU=Example, O=Example, L=Example, ST=Example, C=IN
Signer #1 certificate SHA-256 digest: 046b1a6ae24f12b2d7d2938d7054c06bac308c672981f1bea38d40afb9c23984
Signer #1 certificate SHA-1 digest: d3f77987a973960b360777a5c3f534eb57ee52e5
Signer #1 certificate MD5 digest: 688f275d697fe3630ad377d7ffa8d6e5
Signer #1 key algorithm: RSA
Signer #1 key size (bits): 4096
Signer #1 public key SHA-256 digest: 11f007348e9bc258207c58a819c3ffbde118d99c7f3ecf6fd6ec94db18f768a7
Signer #1 public key SHA-1 digest: aa6c269b71a495699aa6cf32aeae15377713754a
Signer #1 public key MD5 digest: a1c68cc9d3a02954d2ceeeef06f5b3c1

Finally, install over the app-second.apk

adb install app-rotated-2-3.apk 
Performing Streamed Install
Success

For Android 9.0 Pie or later, the further updates required to be signed with the third.jks keystore only. But make sure that you have to keep the signing certificate lineage forever, because in case you have to use a new keystore, you will need it.

adb install app-third.apk
Performing Streamed Install
Success

Reference