Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ dependencies {
implementation(libs.sandwich)
implementation(libs.sandwich.retrofit)
implementation(libs.sandwich.retrofit.serialization)
implementation(libs.tink.android)

ksp(libs.hilt.compiler)

Expand Down
7 changes: 7 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application tools:ignore="MissingApplicationIcon">
<profileable android:shell="true" tools:targetApi="q" />
</application>
</manifest>
7 changes: 7 additions & 0 deletions app/src/debug/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.nativeapptemplate.com</domain>
</domain-config>
</network-security-config>
16 changes: 4 additions & 12 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@

<application
android:name=".NativeAppTemplateApplication"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Nat.Splash"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="tiramisu">
<profileable android:shell="true" tools:targetApi="q" />

<!--
`singleTask` for Background Tag Reading. Avoid running MainActivity onCreate again with Background Tag Reading.
https://qiita.com/takagimeow/items/48b37c55ad8d73d5da88
Expand All @@ -33,14 +33,6 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SENDTO" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mailto" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,44 @@ package com.nativeapptemplate.nativeapptemplatefree.datastore

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.crypto.tink.Aead
import com.google.protobuf.InvalidProtocolBufferException
import com.nativeapptemplate.nativeapptemplatefree.UserPreferences
import java.io.InputStream
import java.io.OutputStream
import java.security.GeneralSecurityException
import javax.inject.Inject

/**
* An [androidx.datastore.core.Serializer] for the [UserPreferences] proto.
*
* Encrypts data at rest using Tink AEAD. On read, falls back to parsing
* unencrypted proto for migration from the previous unencrypted format.
*/
class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> {
class UserPreferencesSerializer @Inject constructor(
private val aead: Aead,
) : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()

override suspend fun readFrom(input: InputStream): UserPreferences =
try {
// readFrom is already called on the data store background thread
UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
override suspend fun readFrom(input: InputStream): UserPreferences {
val bytes = input.readBytes()
if (bytes.isEmpty()) return defaultValue
return try {
val decrypted = aead.decrypt(bytes, null)
UserPreferences.parseFrom(decrypted)
} catch (_: GeneralSecurityException) {
// Fallback: try parsing as unencrypted proto (migration from legacy format).
// The data will be re-encrypted on the next write.
try {
UserPreferences.parseFrom(bytes)
} catch (e: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", e)
}
}
}

override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
// writeTo is already called on the data store background thread
t.writeTo(output)
val encrypted = aead.encrypt(t.toByteArray(), null)
output.write(encrypted)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.nativeapptemplate.nativeapptemplatefree.di.modules

import android.content.Context
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object CryptoModule {

private const val KEYSET_NAME = "nat_datastore_keyset"
private const val PREFERENCE_FILE = "nat_datastore_key_preference"
private const val MASTER_KEY_URI = "android-keystore://nat_datastore_master_key"

@Provides
@Singleton
fun providesAead(@ApplicationContext context: Context): Aead {
AeadConfig.register()
return AndroidKeysetManager.Builder()
.withSharedPref(context, KEYSET_NAME, PREFERENCE_FILE)
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.keysetHandle
.getPrimitive(Aead::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.CertificatePinner
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
Expand Down Expand Up @@ -61,6 +62,14 @@ class NetModule {
.writeTimeout(30, TimeUnit.SECONDS)
.addNetworkInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.certificatePinner(
CertificatePinner.Builder()
// Leaf: api.nativeapptemplate.com
.add("api.nativeapptemplate.com", "sha256/7Thx4p19FEZF2WeuXyjc8kr2t1FtT2zA5wWSWoIhh8A=")
// Intermediate: Google Trust Services WE1
.add("api.nativeapptemplate.com", "sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=")
.build(),
)
.build()

private val json = Json {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ object Utility {
val ndefRecord = ndefRecords.first()
val url = ndefRecord.toUri() ?: return itemTagInfo

if (url.scheme != "https" || url.host != BuildConfig.DOMAIN || url.path != "/${NatConstants.SCAN_PATH}") {
return itemTagInfo
}

val itemTagId = url.getQueryParameter("item_tag_id")
val type = url.getQueryParameter("type")

Expand Down
8 changes: 8 additions & 0 deletions app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
</full-backup-content>
17 changes: 17 additions & 0 deletions app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
6 changes: 6 additions & 0 deletions app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.nativeapptemplate.com</domain>
</domain-config>
</network-security-config>
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
package com.nativeapptemplate.nativeapptemplatefree.datastore

import androidx.datastore.core.CorruptionException
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.aead.AeadConfig
import com.nativeapptemplate.nativeapptemplatefree.userPreferences
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

class UserPreferencesSerializerTest {
private val userPreferencesSerializer = UserPreferencesSerializer()
private lateinit var aead: Aead
private lateinit var userPreferencesSerializer: UserPreferencesSerializer

@Before
fun setUp() {
AeadConfig.register()
val keysetHandle = KeysetHandle.generateNew(KeyTemplates.get("AES256_GCM"))
aead = keysetHandle.getPrimitive(Aead::class.java)
userPreferencesSerializer = UserPreferencesSerializer(aead)
}

@Test
fun defaultUserPreferences_isEmpty() {
Expand All @@ -27,11 +41,28 @@ class UserPreferencesSerializerTest {
}

val outputStream = ByteArrayOutputStream()
userPreferencesSerializer.writeTo(expectedUserPreferences, outputStream)

val inputStream = ByteArrayInputStream(outputStream.toByteArray())
val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream)

kotlin.test.assertEquals(
expectedUserPreferences,
actualUserPreferences,
)
}

@Test
fun readingUnencryptedProto_migratesSuccessfully() = runTest {
val expectedUserPreferences = userPreferences {
isLoggedIn = true
}

// Write unencrypted proto (legacy format)
val outputStream = ByteArrayOutputStream()
expectedUserPreferences.writeTo(outputStream)

val inputStream = ByteArrayInputStream(outputStream.toByteArray())

val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream)

kotlin.test.assertEquals(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.nativeapptemplate.nativeapptemplatefree.datastoreTest

import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.aead.AeadConfig
import com.nativeapptemplate.nativeapptemplatefree.di.modules.CryptoModule
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [CryptoModule::class],
)
internal object TestCryptoModule {
@Provides
@Singleton
fun providesAead(): Aead {
AeadConfig.register()
val keysetHandle = KeysetHandle.generateNew(KeyTemplates.get("AES256_GCM"))
return keysetHandle.getPrimitive(Aead::class.java)
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ksp = "2.3.4"
lottie = "6.6.6"
okHttp = "4.12.0"
protobuf = "4.29.2"
tinkAndroid = "1.16.0"
protobufPlugin = "0.9.6"
retrofit = "2.11.0"
retrofitKotlinxSerializationJson = "1.0.0"
Expand Down Expand Up @@ -75,6 +76,7 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit
retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
sandwich = { module = "com.github.skydoves:sandwich", version.ref = "sandwich" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tinkAndroid" }
sandwich-retrofit = { module = "com.github.skydoves:sandwich-retrofit", version.ref = "sandwich" }
sandwich-retrofit-serialization = { module = "com.github.skydoves:sandwich-retrofit-serialization", version.ref = "sandwichRetrofitSerialization" }

Expand Down
Loading