diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a0bfe6fc3..dd27f6abf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,11 +8,19 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ submodules: true
- name: Set up JDK 21
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: 'temurin'
java-version: 21
cache: gradle
+ - name: Set up Rust
+ working-directory: ina
+ run: |
+ rustup toolchain install
+ rustup target add aarch64-linux-android x86_64-linux-android
+ - run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;30.0.16248370"
- name: Build with Gradle
run: ./gradlew build --no-daemon
diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml
index d14371014..ab7f1d974 100644
--- a/.github/workflows/validate-gradle-wrapper.yml
+++ b/.github/workflows/validate-gradle-wrapper.yml
@@ -8,4 +8,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ submodules: true
- uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..689e87f3d
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "ina"]
+ path = ina
+ url = https://github.com/GrapheneOS/ina
+ branch = main
diff --git a/.idea/.gitignore b/.idea/.gitignore
index bf5f8aea5..4d56b7827 100644
--- a/.idea/.gitignore
+++ b/.idea/.gitignore
@@ -1,6 +1,6 @@
-/compiler.xml
-/jarRepositories.xml
-/misc.xml
-/vcs.xml
-/gradle.xml
-/gradle.properties
+/*
+!/codeStyles/
+!/inspectionProfiles/
+/inspectionProfiles/*
+!/inspectionProfiles/Project_Default.xml
+!/vcs.xml
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..ff4abe9cc
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 45057cd4b..c937880c0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -34,15 +34,15 @@ android {
}
}
- compileSdk = 36
- buildToolsVersion = "36.1.0"
+ compileSdk = 37
+ buildToolsVersion = "37.0.0"
namespace = "app.grapheneos.apps"
defaultConfig {
applicationId = "app.grapheneos.apps"
minSdk = 31
- targetSdk = 36
+ targetSdk = 37
versionCode = 36
versionName = versionCode.toString()
@@ -91,6 +91,8 @@ android {
}
dependencies {
+ implementation(libs.ina)
+
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
diff --git a/app/src/main/java/app/grapheneos/apps/Notifications.kt b/app/src/main/java/app/grapheneos/apps/Notifications.kt
index ad008a9fd..b69badfa5 100644
--- a/app/src/main/java/app/grapheneos/apps/Notifications.kt
+++ b/app/src/main/java/app/grapheneos/apps/Notifications.kt
@@ -19,6 +19,7 @@ import app.grapheneos.apps.util.checkMainThread
object Notifications {
const val ID_PACKAGE_DOWNLOAD = 1
const val ID_AUTO_UPDATE_JOB_STATUS = 2
+ const val ID_AUTO_UPDATE_DOWNLOAD_FAILED = 3
private const val FIRST_DYNAMIC_ID = 1000
diff --git a/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt b/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt
index becc76294..3e42108e5 100644
--- a/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt
+++ b/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt
@@ -36,7 +36,7 @@ class PackageDownloadFgService : Service() {
}
val progress = downloading.sumOf { it.downloadProgress.get() }
- val total = downloading.sumOf { it.downloadTotal }
+ val total = downloading.sumOf { it.downloadTotal.get() }
val remaining = total - progress
val remainingStr = Formatter.formatShortFileSize(appContext, remaining)
diff --git a/app/src/main/java/app/grapheneos/apps/RpcProvider.kt b/app/src/main/java/app/grapheneos/apps/RpcProvider.kt
index 4a4eddf41..a639ecd9e 100644
--- a/app/src/main/java/app/grapheneos/apps/RpcProvider.kt
+++ b/app/src/main/java/app/grapheneos/apps/RpcProvider.kt
@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Bundle
import android.os.Message
import android.os.Messenger
+import android.os.RemoteException
import android.util.Log
import app.grapheneos.apps.autoupdate.AutoUpdatePrefs
import app.grapheneos.apps.core.InstallParams
@@ -16,18 +17,25 @@ import app.grapheneos.apps.core.PackageState
import app.grapheneos.apps.core.startPackageInstall
import app.grapheneos.apps.core.pkgManager
import app.grapheneos.apps.util.getApplicationInfo
+import app.grapheneos.apps.util.getPackageInfoOrNull
+import app.grapheneos.apps.util.isSystemPackage
import app.grapheneos.apps.util.maybeGetParcelable
import app.grapheneos.apps.util.toInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+private const val TAG = "RpcProvider"
+
class RpcProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
when (method) {
"update_package" -> {
val callingPackage = callingPackage!!
+ if (pkgManager.getPackageInfoOrNull(callingPackage)?.isSystemPackage() != true) {
+ return null
+ }
val pkgName = arg!!
val callback = extras?.maybeGetParcelable("callback")
CoroutineScope(Dispatchers.Main).launch {
@@ -35,10 +43,14 @@ class RpcProvider : ContentProvider() {
updatePackage(callingPackage, pkgName)
}
res.exceptionOrNull()?.let {
- Log.e("RpcProvider", method, it)
+ Log.e(TAG, method, it)
}
val resBool = res.getOrNull() ?: false
- callback?.send(Message().apply { arg1 = resBool.toInt() })
+ try {
+ callback?.send(Message().apply { arg1 = resBool.toInt() })
+ } catch (e: RemoteException) {
+ Log.e(TAG, method, e)
+ }
}
return null
}
diff --git a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt
index 98d001ea6..3c50feb08 100644
--- a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt
+++ b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt
@@ -1,8 +1,10 @@
package app.grapheneos.apps.autoupdate
import android.app.Notification
+import android.app.PendingIntent
import android.app.job.JobParameters
import android.app.job.JobService
+import android.content.Intent
import android.text.format.Formatter
import android.util.Log
import androidx.navigation.NavDeepLinkBuilder
@@ -20,7 +22,9 @@ import app.grapheneos.apps.show
import app.grapheneos.apps.ui.DetailsScreen
import app.grapheneos.apps.ui.ErrorDialog
import app.grapheneos.apps.ui.MainActivity
+import app.grapheneos.apps.util.ActivityUtils
import app.grapheneos.apps.util.componentName
+import app.grapheneos.apps.util.intent
import app.grapheneos.apps.util.isAppInstallationAllowed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -84,7 +88,8 @@ class UpdateCheckJob : JobService() {
val config = appResources.configuration
val sumSize = rPackages.sumOf {
- it.collectNeededApks(config).sumOf { it.compressedSize }
+ val state = PackageStates.getPackageState(it.packageName)
+ it.calculateDownloadSize(state, it.collectNeededApks(config))
}.let {
Formatter.formatShortFileSize(appContext, it)
}
@@ -123,10 +128,16 @@ fun showAllUpToDateNotification() {
return
}
+ ActivityUtils.cancelPendingAction(Notifications.ID_AUTO_UPDATE_DOWNLOAD_FAILED)
+
Notifications.builder(Notifications.CH_AUTO_UPDATE_ALL_UP_TO_DATE).run {
setSmallIcon(R.drawable.ic_check)
setContentTitle(R.string.notif_auto_update_all_up_to_date)
- setAutoCancel(false)
+ setAutoCancel(false) // this notification is a security status indicator
+ val contentIntent = PendingIntent.getActivity(appContext, 0,
+ intent().setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ PendingIntent.FLAG_IMMUTABLE)
+ setContentIntent(contentIntent)
show(Notifications.ID_AUTO_UPDATE_JOB_STATUS)
}
}
diff --git a/app/src/main/java/app/grapheneos/apps/core/Errors.kt b/app/src/main/java/app/grapheneos/apps/core/Errors.kt
index f7ab9f883..082c3c1f3 100644
--- a/app/src/main/java/app/grapheneos/apps/core/Errors.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/Errors.kt
@@ -128,6 +128,8 @@ class RepoUpdateError(val throwable: Throwable, val wasUpdateManuallyRequested:
}
}
+class InvalidDeltaBaseApkException(msg: String?) : Exception(msg)
+
// All errors that happen before the installer session is committed are considered to be download
// errors, "download" means "download into PackageInstaller session", not just "download from the
// network"
@@ -142,6 +144,8 @@ class DownloadError(val pkgLabels: List, val throwable: Throwable) : Err
is PackagesBusyException ->
return appResources.getQuantityString(R.plurals.download_error_packages_busy,
throwable.packageNames.size)
+ is InvalidDeltaBaseApkException ->
+ return appResources.getString(R.string.download_error_delta_base_apk_invalid)
}
return null
}
diff --git a/app/src/main/java/app/grapheneos/apps/core/Globals.kt b/app/src/main/java/app/grapheneos/apps/core/Globals.kt
index efe458fc4..7910193f7 100644
--- a/app/src/main/java/app/grapheneos/apps/core/Globals.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/Globals.kt
@@ -50,8 +50,9 @@ val localeManager: LocaleManager? = if (Build.VERSION.SDK_INT >= 33)
appContext.getSystemService()!! else null
val filesDir: File = appContext.filesDir
-val fileForTemporaryFileDescriptor = File(filesDir, "tmp_fd")
val cacheDir: File = appContext.cacheDir
// limit the number of concurrent downloads
val httpDownloadSemaphore = Semaphore(3)
+// limit ina memory use
+val inaPatcherSemaphore = Semaphore(5)
diff --git a/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt b/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt
index 871b279f9..58ef6d8d5 100644
--- a/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt
@@ -165,7 +165,7 @@ private fun startInstallTaskInner(pkgs: List, params: InstallParams,
}
private fun handleInstallTaskError(tasks: List, throwable: Throwable) {
- if (tasks.any { !it.params.isUserInitiated || it.isManuallyCancelled }) {
+ if (tasks.any { it.isManuallyCancelled }) {
return
}
@@ -182,10 +182,17 @@ private fun handleInstallTaskError(tasks: List, throwable: Throwabl
} ?: return
val template = DownloadError(tasks.map { it.rPackage.label }, error)
- val pendingAction = ErrorDialog.createPendingDialog(template)
+ val pendingDialog = ErrorDialog.createPendingDialog(template)
- ActivityUtils.addPendingAction(pendingAction) {
- Notifications.builder(Notifications.CH_PACKAGE_DOWLOAD_FAILED).apply {
+ val notifChannel = if (tasks.any { !it.params.isUserInitiated }) {
+ pendingDialog.notificationId = Notifications.ID_AUTO_UPDATE_DOWNLOAD_FAILED
+ Notifications.CH_AUTO_UPDATE_FAILED
+ } else {
+ Notifications.CH_PACKAGE_DOWLOAD_FAILED
+ }
+
+ ActivityUtils.addPendingAction(pendingDialog) {
+ Notifications.builder(notifChannel).apply {
setSmallIcon(R.drawable.ic_error)
setContentTitle(R.string.notif_download_failed)
setContentText(tasks.map { it.rPackage.label }.joinToString())
diff --git a/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt b/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt
index 3b556a678..50e072ca2 100644
--- a/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt
@@ -19,10 +19,12 @@ import android.system.OsConstants.SEEK_CUR
import android.system.OsConstants.S_IRUSR
import android.system.OsConstants.S_IWUSR
import android.util.Log
+import app.accrescent.ina.Ina
import app.grapheneos.apps.PackageStates
import app.grapheneos.apps.core.InstallerSessions.abandonSession
import app.grapheneos.apps.util.ScopedFileDescriptor
import app.grapheneos.apps.util.checkMainThread
+import app.grapheneos.apps.util.checkSha256
import app.grapheneos.apps.util.copyTo2
import app.grapheneos.apps.util.getPackageArchiveInfo
import app.grapheneos.apps.util.getPackageInfoOrNull
@@ -39,6 +41,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
@@ -47,15 +50,12 @@ import java.io.File
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
+import java.io.IOException
import java.lang.reflect.Method
import java.net.HttpURLConnection.HTTP_PARTIAL
-import java.security.DigestOutputStream
import java.security.GeneralSecurityException
-import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicLong
-import java.util.zip.GZIPInputStream
import javax.net.ssl.HttpsURLConnection.HTTP_OK
-import kotlin.coroutines.coroutineContext
class InstallTask(
val rPackage: RPackage,
@@ -65,7 +65,10 @@ class InstallTask(
val callbackBeforeCommit: (suspend () -> Unit)?,
) {
val downloadProgress = AtomicLong()
- val downloadTotal = apks.sumOf { it.compressedSize }
+ val currentVersion = packageState.osPackageInfo?.longVersionCode
+ val deltaSourceIndex: Int = rPackage.getDeltaSourceIndex(packageState)
+ val installedSplits: Map = packageState.installedSplits
+ val downloadTotal = AtomicLong(rPackage.calculateDownloadSize(deltaSourceIndex, installedSplits, apks))
init {
checkMainThread()
@@ -91,7 +94,7 @@ class InstallTask(
suspend fun run(): Deferred {
throwIfAppInstallationNotAllowed()
- job = coroutineContext.job
+ job = currentCoroutineContext().job
val sessionId = InstallerSessions.createSession(makeSessionParams(), packageState)
var shouldAbandonSession = true
@@ -140,7 +143,14 @@ class InstallTask(
coroutineScope {
apks.forEach { apk ->
launch {
- obtainAndWriteApk(apk, session)
+ val deltaBaseFd = obtainDeltaBaseFd(apk)
+ try {
+ obtainAndWriteApk(apk, session, deltaBaseFd)
+ } finally {
+ if (deltaBaseFd != null) {
+ Os.close(deltaBaseFd)
+ }
+ }
}
}
@@ -148,7 +158,8 @@ class InstallTask(
apks.forEach { apk ->
launch {
val name = "${apk.name}.idsig"
- obtainAndWriteV4OrFsvSigSignature(name, name, session)
+ obtainAndWriteV4OrFsvSigSignature(name, name,
+ apk.v4SignatureSize.toLong(), apk.v4SignatureSha256!!, session)
}
}
}
@@ -161,7 +172,8 @@ class InstallTask(
val downloadName = "${apk.name}.${certId}.fsv_sig"
// OS requires this format for file name that is written into session
val name = "${apk.name}.fsv_sig"
- obtainAndWriteV4OrFsvSigSignature(downloadName, name, session,
+ obtainAndWriteV4OrFsvSigSignature(downloadName, name,
+ apk.fsvSignatureSize.toLong(), apk.fsvSignatureSha256!!, session,
// fsv_sig signatures are small (typically under 1K) and don't compress well
disableCompression = true)
}
@@ -200,9 +212,10 @@ class InstallTask(
(splitSourceDirs + appInfo.sourceDir).forEach { apkPath ->
val apkFile = File(apkPath)
launch {
+ val apkSize = apkFile.length()
apkFile.inputStream().use { input ->
- session.openWrite(apkFile.name, 0L, apkFile.length()).use { output ->
- input.copyTo2(output, job)
+ session.openWrite(apkFile.name, 0L, apkSize).use { output ->
+ input.copyTo2(apkSize, output, job, apkFile.path)
}}
}
}
@@ -245,43 +258,105 @@ class InstallTask(
}
}
- private suspend fun obtainAndWriteApk(apk: Apk, session: Session) {
- val file = File(apksDir, "${apk.name}.gz")
+ private fun obtainDeltaBaseFd(apk: Apk): FileDescriptor? {
+ val deltaBasePath: String? =
+ if (deltaSourceIndex >= 0 && apk.deltaSizes[deltaSourceIndex] > 0) {
+ installedSplits[apk.name]
+ } else {
+ null
+ }
+ if (deltaBasePath == null) {
+ return null
+ }
+ var result: FileDescriptor? = null
+ try {
+ val fd = try {
+ Os.open(deltaBasePath, O_RDONLY, 0)
+ } catch (e: ErrnoException) {
+ Log.w("obtainDeltaBaseFd", "unable to open $deltaBasePath", e)
+ return null
+ }
+
+ try {
+ if (checkSha256(fd, Os.fstat(fd).st_size, apk.deltasSrcSha256[deltaSourceIndex])) {
+ lseekToStart(fd)
+ result = fd
+ } else {
+ if (params.isUserInitiated) {
+ mainHandler.post {
+ packageState.ignoreDeltasForVersion(currentVersion!!)
+ }
+ throw InvalidDeltaBaseApkException("SHA-256 check failed for $deltaBasePath")
+ } else {
+ Log.w("obtainDeltaBaseFd", "SHA-256 check failed for $deltaBasePath, falling back to full APK download")
+ }
+ }
+ } finally {
+ if (result !== fd) {
+ Os.close(fd)
+ }
+ }
+ } finally {
+ if (result == null) {
+ // download will fall back to the full compressed APK
+ downloadTotal.addAndGet(apk.compressedSize - apk.deltaSizes[deltaSourceIndex])
+ }
+ }
+ return result
+ }
+
+ private suspend fun obtainAndWriteApk(apk: Apk, session: Session, deltaBaseFd: FileDescriptor?) {
+ val fileName = StringBuilder(apk.name).run {
+ append('.')
+ if (deltaBaseFd != null) {
+ append(rPackage.availableDeltaVersions[deltaSourceIndex])
+ append(".ina")
+ } else {
+ append("br")
+ }
+ toString()
+ }
+ val file = File(apksDir, fileName)
val path = file.path
val tmpPath = "$path.tmp"
+ val fullSize = if (deltaBaseFd != null) apk.deltaSizes[deltaSourceIndex] else apk.compressedSize
// Check whether this apk is already partially or fully downloaded
try {
Os.open(path, O_RDONLY, 0)
- } catch (e: ErrnoException) {
+ } catch (_: ErrnoException) {
null
}?.let {
ScopedFileDescriptor(it).use { fd ->
- val curSize = Os.fstat(fd.v).st_size
- val fullSize = apk.compressedSize
+ var curSize = Os.fstat(fd.v).st_size
// apk is already fully downloaded
if (curSize == fullSize) {
downloadProgress.getAndAdd(curSize)
- uncompressAndWriteApk(fd.v, apk, session)
+ uncompressAndWriteApk(fd.v, path, fullSize, apk, deltaBaseFd, session)
return
}
- check(curSize < fullSize) { "unexpected size of $path: $curSize, max expected ${fullSize}" }
+ if (curSize > fullSize) {
+ Log.e("obtainAndWriteApk", "unexpected size of $path: $curSize, max expected $fullSize")
+ curSize = 0
+ }
// Copy incomplete apk into a tmp file. Could have renamed apk file into tmp file directly,
// but this would make things more complicated and brittle, because rename and write
// to file may get reordered before they are actually written to storage, which
// could corrupt file's contents
openTempFileFd(tmpPath).use { tmpFd ->
- sendfile(tmpFd.v, fd.v, curSize)
- check(Os.lseek(tmpFd.v, 0L, SEEK_CUR) == curSize)
- downloadProgress.getAndAdd(curSize)
+ if (curSize > 0) {
+ sendfile(tmpFd.v, fd.v, curSize)
+ check(Os.lseek(tmpFd.v, 0L, SEEK_CUR) == curSize)
+ downloadProgress.getAndAdd(curSize)
+ }
try {
- download(apk.downloadUrl(), tmpFd.v, curSize, fullSize)
+ download(apk.downloadUrl(fileName), tmpFd.v, curSize, fullSize)
} finally {
fsyncAndRename(tmpFd.v, tmpPath, path)
}
- uncompressAndWriteApk(tmpFd.v, apk, session)
+ uncompressAndWriteApk(tmpFd.v, path, fullSize, apk, deltaBaseFd, session)
}
}
return
@@ -290,40 +365,44 @@ class InstallTask(
// cached apk not found
openTempFileFd(tmpPath).use { tmpFd ->
try {
- download(apk.downloadUrl(), tmpFd.v, curSize = 0L, apk.compressedSize)
+ download(apk.downloadUrl(fileName), tmpFd.v, curSize = 0L, fullSize)
} finally {
fsyncAndRename(tmpFd.v, tmpPath, path)
}
- uncompressAndWriteApk(tmpFd.v, apk, session)
+ uncompressAndWriteApk(tmpFd.v, path, fullSize, apk, deltaBaseFd, session)
}
}
- private suspend fun obtainAndWriteV4OrFsvSigSignature(downloadName: String, name: String, session: Session, disableCompression: Boolean = false) {
+ private suspend fun obtainAndWriteV4OrFsvSigSignature(downloadName: String, name: String, size: Long, sha256: ByteArray, session: Session, disableCompression: Boolean = false) {
+ val logTag = "obtainApkSignature"
val file = File(apksDir, downloadName)
val path = file.path
// v4 and fsv_sig signatures are usually small, skip download resume support for simplicity
- // v4 and fsv_sig signatures don't have SHA-256 checksums:
- // - v4 signatures are verified against the corresponding APKs by the OS before installation
- // - for fsv_sig signatures, kernel enforces that they are valid and come from a trusted
- // certificate that is stored in the immutable system image
-
// Check whether this signature is already downloaded
try {
Os.open(path, O_RDONLY, 0)
- } catch (e: ErrnoException) {
+ } catch (_: ErrnoException) {
null
}?.let {
+ var written = false
ScopedFileDescriptor(it).use { fd ->
- val fileSize = Os.fstat(fd.v).st_size
-
- FileInputStream(fd.v).use { inputStream ->
- session.openWrite(name, 0, fileSize).use { outputStream ->
- inputStream.copyTo2(outputStream, job)
- }}
+ if (!checkSha256(fd.v, size, sha256)) {
+ Log.e(logTag, "sha256 mismatch for cached $path")
+ } else {
+ lseekToStart(fd.v)
+ FileInputStream(fd.v).use { inputStream ->
+ session.openWrite(name, 0, size).use { outputStream ->
+ inputStream.copyTo2(size, outputStream, job, path)
+ written = true
+ }
+ }
+ }
+ }
+ if (written) {
+ return
}
- return
}
val tmpPath = "$path.tmp"
@@ -346,39 +425,87 @@ class InstallTask(
conn.v.inputStream.use { inputStream ->
FileOutputStream(tmpFd.v).use { outputStream ->
- inputStream.copyTo2(outputStream, job, downloadProgress)
+ inputStream.copyTo2(size, outputStream, job, url, downloadProgress)
}}
}
}
- fsyncAndRename(tmpFd.v, tmpPath, path)
-
- val fileSize = downloadProgress.get()
lseekToStart(tmpFd.v)
+ if (!checkSha256(tmpFd.v, size, sha256)) {
+ throw GeneralSecurityException("sha256 mismatch for $path")
+ }
+ lseekToStart(tmpFd.v)
+
+ fsyncAndRename(tmpFd.v, tmpPath, path)
FileInputStream(tmpFd.v).use { inputStream ->
- session.openWrite(name, 0, fileSize).use { outputStream ->
- inputStream.copyTo2(outputStream, job)
- }}
+ session.openWrite(name, 0, size).use { outputStream ->
+ inputStream.copyTo2(size, outputStream, job, name)
+ }
+ }
}
}
- private fun uncompressAndWriteApk(compressedFd: FileDescriptor, apk: Apk, session: Session) {
- lseekToStart(compressedFd)
+ // file descriptor arguments are never closed by this function
+ @Throws(IOException::class)
+ private suspend fun inaPatch(oldFileFd: FileDescriptor, patch: FileDescriptor, new: FileDescriptor): Long {
+ return inaPatcherSemaphore.withPermit {
+ Ina.patch(oldFileFd, patch, new)
+ }
+ }
+
+ private suspend fun uncompressAndWriteApk(
+ compressedFd: FileDescriptor,
+ compressedPath: String,
+ compressedSize: Long,
+ apk: Apk,
+ deltaBaseFd: FileDescriptor?,
+ session: Session
+ ) {
+ val compressedSha256: ByteArray = if (deltaBaseFd != null) {
+ apk.deltasSha256[deltaSourceIndex]
+ } else {
+ apk.compressedSha256
+ }
- val sha256 = MessageDigest.getInstance("SHA-256")
+ lseekToStart(compressedFd)
+ if (!checkSha256(compressedFd, compressedSize, compressedSha256)) {
+ File(compressedPath).delete()
+ throw GeneralSecurityException("sha256 mismatch for " +
+ "${if (deltaBaseFd != null) "delta" else "compressed"} file for ${apk.name}")
+ }
+ lseekToStart(compressedFd)
makeTemporaryFileDescriptor().use { uncompressedFd ->
- GZIPInputStream(FileInputStream(compressedFd), DEFAULT_BUFFER_SIZE).use { inputStream ->
- DigestOutputStream(FileOutputStream(uncompressedFd.v), sha256).use { outputStream ->
- val bytesCopied = inputStream.copyTo2(outputStream, job)
- if (bytesCopied != apk.size) {
- throw GeneralSecurityException("size mismatch for file ${apk.name}")
+ if (deltaBaseFd != null) {
+ try {
+ val res = inaPatch(deltaBaseFd, compressedFd, uncompressedFd.v)
+ if (res != apk.size) {
+ throw GeneralSecurityException("patched size mismatch for ${apk.name}: expected ${apk.size}, got $res")
+ }
+ lseekToStart(uncompressedFd.v)
+ if (!checkSha256(uncompressedFd.v, apk.size, apk.sha256)) {
+ throw GeneralSecurityException("sha256 mismatch for patched ${apk.name}")
+ }
+ } catch (e: Exception) {
+ when (e) {
+ is ErrnoException, is GeneralSecurityException, is IOException -> {
+ mainHandler.post {
+ packageState.ignoreDeltasForVersion(currentVersion!!)
+ }
+ }
+ }
+ throw e
+ }
+ } else {
+ val res = Ina.unbrotli(compressedFd, uncompressedFd.v)
+ if (res != apk.size) {
+ throw GeneralSecurityException("uncompressed size mismatch for ${apk.name}")
+ }
+ lseekToStart(uncompressedFd.v)
+ if (!checkSha256(uncompressedFd.v, apk.size, apk.sha256)) {
+ throw GeneralSecurityException("sha256 mismatch for uncompressed ${apk.name}")
}
- }}
-
- if (!sha256.digest().contentEquals(apk.sha256)) {
- throw GeneralSecurityException("sha256 mismatch for file ${apk.name}")
}
lseekToStart(uncompressedFd.v)
@@ -420,7 +547,7 @@ class InstallTask(
FileInputStream(uncompressedFd.v).use { inputStream ->
session.openWrite(apk.name, 0, apk.size).use { outputStream ->
- inputStream.copyTo2(outputStream, job)
+ inputStream.copyTo2(apk.size, outputStream, job, apk.name)
}}
}
}
@@ -441,9 +568,11 @@ class InstallTask(
throwResponseCodeException(conn.v)
}
+ val bytesToCopy = fullSize - curSize
+
conn.v.inputStream.use { input ->
FileOutputStream(fd).use { output ->
- input.copyTo2(output, job, progress = downloadProgress)
+ input.copyTo2(bytesToCopy, output, job, url, progress = downloadProgress)
}}
}
}
@@ -503,7 +632,7 @@ class InstallTask(
throw UnsupportedOperationException("Multi-package sessions aren't supported properly by Android 12 for unprivileged installers")
}
- val job = coroutineContext.job
+ val job = currentCoroutineContext().job
var shouldAbandonSession = true
val parentSessionId = InstallerSessions.createMultiPackageSession()
@@ -562,7 +691,7 @@ class InstallTask(
try {
findPackageMethod = pkgManager.javaClass.getDeclaredMethod("findPackage",
String::class.java, java.lang.Long.TYPE, Bundle::class.java)
- } catch (ignored: ReflectiveOperationException) {}
+ } catch (_: ReflectiveOperationException) {}
findPackageMethodInited = true
}
diff --git a/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt b/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt
index 6f208c0ef..6fc4e3804 100644
--- a/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt
@@ -239,14 +239,20 @@ object InstallerSessions {
fun abandonSession(sessionId: Int): Boolean {
// sessionIds are not reused until OS reboots, no need to synchronize
- return try {
+ try {
pkgInstaller.abandonSession(sessionId)
- true
+ return true
} catch (e: Exception) {
// abandonSession() docs say that it throws only SecurityException, but source code
// shows that IllegalStateException might be thrown in some cases too
- Log.d(TAG, "", e)
- false
+ when (e) {
+ is IllegalStateException, is SecurityException -> {
+ Log.d(TAG, "", e)
+ return false
+ }
+
+ else -> throw e
+ }
}
}
}
diff --git a/app/src/main/java/app/grapheneos/apps/core/PackageState.kt b/app/src/main/java/app/grapheneos/apps/core/PackageState.kt
index 470f9833c..522f92664 100644
--- a/app/src/main/java/app/grapheneos/apps/core/PackageState.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/PackageState.kt
@@ -5,14 +5,27 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.os.LocaleList
import android.text.format.Formatter
+import android.util.ArrayMap
import app.grapheneos.apps.PackageStates
import app.grapheneos.apps.R
import app.grapheneos.apps.util.appendRes
+import app.grapheneos.apps.util.checkMainThread
+import java.util.Collections
// Access only from the main thread
class PackageState(val pkgName: String, val id: Long) {
lateinit var rPackage: RPackage; private set
+ var installedSplits: Map = emptyMap()
var osPackageInfo: PackageInfo? = null
+ set(value) {
+ field = value
+ installedSplits = getSplitsMap(value)
+ onDeltaStatusChanged()
+ }
+
+ var ignoreDeltasForVersion: Long? = null
+ private set
+
var releaseChannelOverride: ReleaseChannel? = null
var pkgSpecificLocales: LocaleList? = null
@@ -53,13 +66,48 @@ class PackageState(val pkgName: String, val id: Long) {
fun getDownloadSizeUiString(): String {
cachedDownloadSize?.let { return it }
- val size = rPackage.collectNeededApks(appResources.configuration).sumOf { it.compressedSize }
+ val size = rPackage.calculateDownloadSize(this, rPackage.collectNeededApks(appResources.configuration))
Formatter.formatShortFileSize(appContext, size).let {
cachedDownloadSize = it
return it
}
}
+ fun ignoreDeltasForVersion(versionCode: Long) {
+ checkMainThread()
+ this.ignoreDeltasForVersion = versionCode
+ onDeltaStatusChanged()
+ notifyListeners()
+ }
+
+ fun onDeltaStatusChanged() {
+ cachedDownloadSize = null
+ }
+
+ private fun getSplitsMap(pkgInfo: PackageInfo?): Map {
+ val appInfo = pkgInfo?.applicationInfo ?: return emptyMap()
+ val baseApkPath = checkNotNull(appInfo.sourceDir)
+ val baseApkName = "base.apk"
+ val splitNames = appInfo.splitNames
+ if (splitNames == null) {
+ return Collections.singletonMap(baseApkName, baseApkPath)
+ }
+ val numSplits = splitNames.size
+ val splitPaths = requireNotNull(appInfo.splitSourceDirs)
+ check(splitPaths.size == numSplits)
+ val res = ArrayMap(numSplits + 1)
+ res.put(baseApkName, baseApkPath)
+ val b = StringBuilder()
+ for (i in 0 until numSplits) {
+ b.setLength(0)
+ b.append("split_")
+ b.append(checkNotNull(splitNames[i]))
+ b.append(".apk")
+ check(res.put(b.toString(), checkNotNull(splitPaths[i])) == null)
+ }
+ return res
+ }
+
private fun appendDots(sb: StringBuilder) {
repeat(PackageStates.updateLoopRunnableRunCount and 0b11) {
sb.append('.')
@@ -113,7 +161,7 @@ class PackageState(val pkgName: String, val id: Long) {
}
check(taskState == InstallTask.STATE_DOWNLOADING)
val progress = installTask.downloadProgress.get()
- val total = installTask.downloadTotal
+ val total = installTask.downloadTotal.get()
if (progress == total) {
val sb = StringBuilder()
sb.appendRes(ctx, R.string.pkg_status_unpacking)
@@ -125,7 +173,7 @@ class PackageState(val pkgName: String, val id: Long) {
else R.string.pkg_status_downloading
val percent = ((progress.toDouble() / total.toDouble()) * 100.0).toInt()
return ctx.getString(ref, percent,
- Formatter.formatShortFileSize(ctx, installTask.downloadTotal)
+ Formatter.formatShortFileSize(ctx, total)
)
}
}
diff --git a/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt b/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt
index ccfbf3b3d..587634e61 100644
--- a/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt
@@ -17,41 +17,43 @@ import androidx.core.util.valueIterator
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
-import app.grapheneos.apps.core.appContext
-import app.grapheneos.apps.core.appResources
-import app.grapheneos.apps.core.mainHandler
-import app.grapheneos.apps.core.pkgManager
import app.grapheneos.apps.core.InstallTask
import app.grapheneos.apps.core.InstallerSessions
import app.grapheneos.apps.core.InstallerSessions.installerSessionMap
-import app.grapheneos.apps.util.ActivityUtils
-import app.grapheneos.apps.util.checkMainThread
-import app.grapheneos.apps.util.forEachEntry
-import app.grapheneos.apps.util.getPackageInfoOrNull
-import app.grapheneos.apps.util.getSharedPreferences
-import app.grapheneos.apps.util.intent
-import app.grapheneos.apps.util.invokeOnCompletionOnMainThread
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.async
-import kotlinx.coroutines.launch
import app.grapheneos.apps.core.PackageState
import app.grapheneos.apps.core.RPackageContainer
import app.grapheneos.apps.core.ReleaseChannel
import app.grapheneos.apps.core.Repo
import app.grapheneos.apps.core.RepoUpdateError
+import app.grapheneos.apps.core.appContext
+import app.grapheneos.apps.core.appResources
import app.grapheneos.apps.core.fetchRepo
import app.grapheneos.apps.core.getCachedRepo
+import app.grapheneos.apps.core.mainHandler
+import app.grapheneos.apps.core.pkgManager
import app.grapheneos.apps.core.prunePackageCache
+import app.grapheneos.apps.util.ActivityUtils
+import app.grapheneos.apps.util.checkMainThread
+import app.grapheneos.apps.util.forEachEntry
+import app.grapheneos.apps.util.getPackageInfoOrNull
import app.grapheneos.apps.util.getParcelableOrThrow
+import app.grapheneos.apps.util.getSharedPreferences
+import app.grapheneos.apps.util.intent
+import app.grapheneos.apps.util.invokeOnCompletionOnMainThread
import app.grapheneos.apps.util.simpleName
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
+import com.github.michaelbull.retry.policy.RetryPolicy
import com.github.michaelbull.retry.policy.binaryExponentialBackoff
+import com.github.michaelbull.retry.policy.stopAtAttempts
import com.github.michaelbull.retry.result.retry
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@@ -186,7 +188,7 @@ object PackageStates : LifecycleEventObserver {
}
suspend fun requestRepoUpdateRetrying(force: Boolean = false, isManuallyRequested: Boolean = false): RepoUpdateError? {
- val (_, err) = retry(binaryExponentialBackoff(1_000, 30_000)) {
+ val (_, err) = retry(RetryPolicy(binaryExponentialBackoff(1_000, 30_000), stopAtAttempts(5))) {
val err = requestRepoUpdate(force, isManuallyRequested)
if (err != null) Err(err) else Ok(Unit)
}
@@ -288,7 +290,7 @@ object PackageStates : LifecycleEventObserver {
}
private fun updateDownloadFgServiceState() {
- if (installTasks.sumOf { it.downloadProgress.get() } < installTasks.sumOf { it.downloadTotal }) {
+ if (installTasks.sumOf { it.downloadProgress.get() } < installTasks.sumOf { it.downloadTotal.get() }) {
if (serviceStarted || ActivityUtils.mostRecentResumedActivity() != null) {
val notif = PackageDownloadFgService.createNotification(installTasks)
if (notif != null) {
diff --git a/app/src/main/java/app/grapheneos/apps/core/Repo.kt b/app/src/main/java/app/grapheneos/apps/core/Repo.kt
index 610acab3c..929c43efd 100644
--- a/app/src/main/java/app/grapheneos/apps/core/Repo.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/Repo.kt
@@ -198,12 +198,14 @@ class RPackageContainer(val repo: Repo, val packageName: String,
null
}
}
+ val iconSha256: String? = if (iconUrl != null) json.getString("iconSha256") else null
+ val iconSize: Int = if (iconUrl != null) json.getInt("iconSize") else 0
// Used for setting release channel for packages that are closely linked together.
// This allows to significantly simplify the dependency resolution process (otherwise release
// channel could be changed for each of them independently)
val group: RPackageGroup? = run {
- val groupName = json.opt("group") as String? ?: return@run null
+ val groupName = json.opt("group") as? String ?: return@run null
val group = repo.groups.getOrPut(groupName) {
RPackageGroup(groupName)
}
@@ -302,6 +304,14 @@ private fun parseDependencies(json: JSONObject, repo: Repo): Array?
}
}
+private val emptyByteArray = ByteArray(0)
+private val emptyLongArray = LongArray(0)
+private val emptyJsonArray = JSONArray()
+
+private fun JSONObject.optJSONArray2(name: String): JSONArray {
+ return opt(name) as? JSONArray ?: emptyJsonArray
+}
+
// "Repo package"
class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: Array?, repo: Repo, json: JSONObject) {
val packageName: String = common.packageName
@@ -319,29 +329,76 @@ class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: A
val releaseChannel = ReleaseChannel.valueOf(json.optString("channel", ReleaseChannel.stable.name))
+ val availableDeltaVersions: LongArray =
+ jsonArrayToLongArray(json.optJSONArray2("availableDeltaVersions"))
+
val apks: List = run {
val names = json.getJSONArray("apks")
val hashes = json.getJSONArray("apkHashes")
val sizes = json.getJSONArray("apkSizes")
- val gzSizes = json.getJSONArray("apkGzSizes")
+ val brSizes = json.getJSONArray("apkBrSizes")
+ val brHashes = json.getJSONArray("apkBrHashes")
+ val v4SignatureSizes = json.optJSONArray("v4SignatureSizes")
+ val v4SignatureHashes = json.optJSONArray("v4SignatureHashes")
+ val fsvSignatureSizes = if (Build.VERSION.SDK_INT < 35) json.optJSONArray("fsvSignatureSizes") else null
+ val fsvSignatureHashes = if (Build.VERSION.SDK_INT < 35) json.optJSONArray("fsvSignatureHashes") else null
+ val allDeltaSrcHashes = json.optJSONArray2("apkDeltaSrcHashes")
+ val allDeltaSizes = json.optJSONArray2("apkDeltaSizes")
+ val allDeltaHashes = json.optJSONArray2("apkDeltaHashes")
+ val numDeltas = availableDeltaVersions.size
+ var deltasOffset = 0
val len = names.length()
+ require(numDeltas * len == allDeltaSizes.length())
+ require(allDeltaSizes.length() == allDeltaHashes.length())
+ require(allDeltaSizes.length() == allDeltaSrcHashes.length())
require(hashes.length() == len)
require(sizes.length() == len)
- require(gzSizes.length() == len)
+ require(brSizes.length() == len)
+ require(brHashes.length() == len)
val list = ArrayList(len)
for (i in 0 until len) {
val name = names.getString(i)
- val sha256 = hexStringToByteArray(hashes.getString(i))
- require(sha256.size == (256 / 8))
- val apk = Apk(this, name, sha256, sizes.getLong(i), gzSizes.getLong(i))
+ val sha256 = sha256FromJsonArr(hashes, i)
+ val deltaSizes = jsonArrayToLongArray(allDeltaSizes, deltasOffset, deltasOffset + numDeltas)
+ val deltasSrcSha256 = Array(numDeltas) { idx ->
+ if (deltaSizes[idx] == 0L) {
+ emptyByteArray
+ } else {
+ sha256FromJsonArr(allDeltaSrcHashes, deltasOffset + idx)
+ }
+ }
+ val deltasSha256 = Array(numDeltas) { idx ->
+ if (deltaSizes[idx] == 0L) {
+ emptyByteArray
+ } else {
+ sha256FromJsonArr(allDeltaHashes, deltasOffset + idx)
+ }
+ }
+ deltasOffset += numDeltas
+ val apk = Apk(
+ this,
+ name,
+ sha256,
+ sizes.getLong(i),
+ brSizes.getLong(i),
+ sha256FromJsonArr(brHashes, i),
+ deltasSrcSha256,
+ deltaSizes,
+ deltasSha256,
+ v4SignatureSizes?.getInt(i) ?: 0,
+ v4SignatureHashes?.let { sha256FromJsonArr(it, i) },
+ fsvSignatureSizes?.getInt(i) ?: 0,
+ fsvSignatureHashes?.let { sha256FromJsonArr(it, i) },
+ )
if (apk.type == Apk.Type.ABI && apk.qualifier != deviceAbi.apkSplitQualifier) {
continue
}
list.add(apk)
}
+ check(deltasOffset == allDeltaSizes.length())
list
}
@@ -354,6 +411,37 @@ class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: A
false
}
+ fun getDeltaSourceIndex(state: PackageState): Int {
+ val pkgInfo = state.osPackageInfo
+ if (pkgInfo != null) {
+ val installedVersion = pkgInfo.longVersionCode
+ val ignoredVersion = state.ignoreDeltasForVersion
+ if (ignoredVersion == null || installedVersion != ignoredVersion) {
+ return availableDeltaVersions.indexOf(installedVersion)
+ }
+ }
+ return -1
+ }
+
+ // all apks from the list should be from the apks list for this package
+ fun calculateDownloadSize(state: PackageState, apks: List): Long {
+ return calculateDownloadSize(getDeltaSourceIndex(state), state.installedSplits, apks)
+ }
+
+ fun calculateDownloadSize(deltaSourceIndex: Int, installedSplits: Map, apks: List): Long {
+ if (deltaSourceIndex >= 0) {
+ return apks.sumOf {
+ if (installedSplits.contains(it.name)) {
+ val deltaSize = it.deltaSizes[deltaSourceIndex]
+ if (deltaSize > 0) deltaSize else it.compressedSize
+ } else {
+ it.compressedSize
+ }
+ }
+ }
+ return apks.sumOf { it.compressedSize }
+ }
+
fun collectNeededApks(config: Configuration): List {
checkMainThread()
@@ -448,7 +536,13 @@ class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: A
// getApplicationLocales() is allowed only if we are currently the
// installer-of-record for this package. It also could have been racily
// uninstalled, which results in an IllegalArgumentException
- LocaleList.getEmptyLocaleList()
+ when (e) {
+ is IllegalArgumentException, is SecurityException -> {
+ LocaleList.getEmptyLocaleList()
+ }
+
+ else -> throw e
+ }
}
pkgState.pkgSpecificLocales = list
list
@@ -479,6 +573,14 @@ class Apk(
val sha256: ByteArray,
val size: Long,
val compressedSize: Long,
+ val compressedSha256: ByteArray,
+ val deltasSrcSha256: Array,
+ val deltaSizes: LongArray,
+ val deltasSha256: Array,
+ val v4SignatureSize: Int,
+ val v4SignatureSha256: ByteArray?,
+ val fsvSignatureSize: Int,
+ val fsvSignatureSha256: ByteArray?,
) {
var qualifier = ""
@@ -501,7 +603,7 @@ class Apk(
}
}
- fun downloadUrl() = "$REPO_BASE_URL/packages/${pkg.manifestPackageName}/${pkg.versionCode}/$name.gz"
+ fun downloadUrl(fileName: String) = "$REPO_BASE_URL/packages/${pkg.manifestPackageName}/${pkg.versionCode}/${fileName}"
enum class Type {
UNCONDITIONAL,
@@ -604,6 +706,16 @@ private fun checkPackageDep(dep: ComplexDependency, dependentManifestPkgName: St
return dep.check(pi.longVersionCode)
}
+private fun sha256FromJsonArr(ja: JSONArray, idx: Int): ByteArray {
+ return sha256FromString(ja.getString(idx))
+}
+
+fun sha256FromString(str: String): ByteArray {
+ val res = hexStringToByteArray(str)
+ require(res.size == (256 / 8))
+ return res
+}
+
private fun hexStringToByteArray(s: String): ByteArray {
// each byte takes 2 characters, so length must be even
require(s.length.isEven())
@@ -619,6 +731,18 @@ private fun hexStringToByteArray(s: String): ByteArray {
return arr
}
+private fun jsonArrayToLongArray(ja: JSONArray, from: Int = 0, to: Int = ja.length()): LongArray {
+ val len = to - from
+ if (len == 0) {
+ return emptyLongArray
+ }
+ val res = LongArray(len)
+ for (i in 0 until len) {
+ res[i] = ja.getLong(from + i)
+ }
+ return res
+}
+
private fun shouldSkipRenamedPackages(): Boolean {
val buildIncremental: Long? = Build.VERSION.INCREMENTAL.toLongOrNull()
return buildIncremental != null
diff --git a/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt b/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt
index 11242da60..fe3095a4b 100644
--- a/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt
+++ b/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt
@@ -20,9 +20,9 @@ import java.nio.charset.StandardCharsets.UTF_8
import java.security.GeneralSecurityException
private const val METADATA_VERSION = 1
-private const val CACHE_FILE_VERSION = 1
+private const val CACHE_FILE_VERSION = 2
-private const val MIN_TIMESTAMP = 1_770_000_000L
+private const val MIN_TIMESTAMP = 1_792_550_000L
private const val PUBLIC_KEY = BuildConfig.REPO_PUBLIC_KEY
private const val KEY_VERSION = BuildConfig.REPO_KEY_VERSION
@@ -53,7 +53,7 @@ fun fetchRepo(currentRepo: Repo): Repo {
throwResponseCodeException(conn.v)
}
- fetchInner(conn.v, MIN_TIMESTAMP)
+ fetchInner(conn.v, maxOf(currentRepo.timestamp, MIN_TIMESTAMP))
}
}
}
@@ -110,13 +110,17 @@ fun getCachedRepo(): Repo {
val json = dis.readByteArray().toString(UTF_8)
check(dis.available() == 0)
+ if (fileVersion != CACHE_FILE_VERSION) {
+ return createDummy(JSONObject(json).getLong("time"))
+ }
+
return Repo(JSONObject(json), eTag)
}
// make a dummy repo to remove the need to check for null Repo everywhere
-private fun createDummy(): Repo {
+private fun createDummy(minTimestamp: Long = MIN_TIMESTAMP): Repo {
val jo = JSONObject().apply {
- put("time", MIN_TIMESTAMP)
+ put("time", maxOf(MIN_TIMESTAMP, minTimestamp))
put("packages", JSONObject())
}
return Repo(jo, eTag = "", isDummy = true)
diff --git a/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt b/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt
index f53362810..b9fb0a641 100644
--- a/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt
+++ b/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt
@@ -5,6 +5,7 @@ import android.app.Notification
import android.content.Intent
import android.os.Bundle
import android.service.notification.StatusBarNotification
+import android.util.ArraySet
import androidx.annotation.IdRes
import androidx.core.os.postDelayed
import app.grapheneos.apps.Notifications
@@ -34,6 +35,8 @@ object ActivityUtils {
}
}
+ private val delayedPendingActions = ArraySet()
+
fun addPendingAction(action: PendingAction, notification: Supplier) {
checkMainThread()
@@ -46,12 +49,21 @@ object ActivityUtils {
// when an OS activity launched by us launches our PendingIntent and immediately finishes.
// In this case, notification would be shown and dismissed immediately, which is undesirable.
// Delay adding of PendingAction for a bit as a workaround
+ check(delayedPendingActions.add(action))
mainHandler.postDelayed(300L) {
+ if (!delayedPendingActions.remove(action)) {
+ return@postDelayed
+ }
+
mostRecentResumedActivity()?.let {
action.launch(it)
return@postDelayed
}
+ if (action.notificationId != 0) {
+ pendingActions.removeAll { it.notificationId == action.notificationId }
+ }
+
pendingActions.add(action)
notification.get().apply {
@@ -69,6 +81,13 @@ object ActivityUtils {
}
}
+ fun cancelPendingAction(notificationId: Int) {
+ checkMainThread()
+ delayedPendingActions.removeIf { it.notificationId == notificationId }
+ pendingActions.removeAll { it.notificationId == notificationId }
+ Notifications.cancel(notificationId)
+ }
+
// Use onResume/onPause instead of onStart/onStop, because the latter are not executed when
// non-full-screen activity is launched by us. Launching another activity in this state (paused
// but not stopped) may interfere with that activity (it'd become stopped)
diff --git a/app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt b/app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt
new file mode 100644
index 000000000..23edd8e41
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt
@@ -0,0 +1,84 @@
+package app.grapheneos.apps.ui
+
+import android.content.Context
+import app.grapheneos.apps.core.sha256FromString
+import app.grapheneos.apps.util.copyTo2
+import app.grapheneos.apps.util.openConnection
+import app.grapheneos.apps.util.throwResponseCodeException
+import com.bumptech.glide.Glide
+import com.bumptech.glide.Priority
+import com.bumptech.glide.Registry
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.Options
+import com.bumptech.glide.load.data.DataFetcher
+import com.bumptech.glide.load.model.ModelLoader
+import com.bumptech.glide.load.model.ModelLoaderFactory
+import com.bumptech.glide.load.model.MultiModelLoaderFactory
+import com.bumptech.glide.module.AppGlideModule
+import com.bumptech.glide.signature.ObjectKey
+import java.io.ByteArrayOutputStream
+import java.net.HttpURLConnection.HTTP_OK
+import java.nio.ByteBuffer
+import java.security.DigestOutputStream
+import java.security.GeneralSecurityException
+import java.security.MessageDigest
+
+@GlideModule
+class AppGlide : AppGlideModule() {
+ override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
+ registry.append(VerifiedUrl::class.java, ByteBuffer::class.java, VerifiedUrlLoader.Factory())
+ }
+}
+
+data class VerifiedUrl(val url: String, val size: Int, val sha256: String)
+
+class VerifiedUrlFetcher(private val verifiedUrl: VerifiedUrl) : DataFetcher {
+
+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) {
+ val result: ByteBuffer
+ try {
+ openConnection(null, verifiedUrl.url, {}).use { conn ->
+ if (conn.v.responseCode != HTTP_OK) {
+ throwResponseCodeException(conn.v)
+ }
+
+ val arrayOutputStream = ByteArrayOutputStream(verifiedUrl.size)
+
+ val sha256 = MessageDigest.getInstance("SHA-256")
+
+ conn.v.inputStream.use { inputStream ->
+ DigestOutputStream(arrayOutputStream, sha256).use { outputStream ->
+ inputStream.copyTo2(verifiedUrl.size.toLong(), outputStream, null, verifiedUrl.url)
+ }
+ }
+
+ if (!sha256.digest().contentEquals(sha256FromString(verifiedUrl.sha256))) {
+ throw GeneralSecurityException("SHA-256 mismatch for ${verifiedUrl.url}")
+ }
+ result = ByteBuffer.wrap(arrayOutputStream.toByteArray())
+ }
+ } catch (e: Exception) {
+ callback.onLoadFailed(e)
+ return
+ }
+ callback.onDataReady(result)
+ }
+
+ override fun cleanup() = Unit
+ override fun cancel() = Unit
+ override fun getDataClass() = ByteBuffer::class.java
+ override fun getDataSource() = DataSource.REMOTE
+}
+
+class VerifiedUrlLoader : ModelLoader {
+ override fun buildLoadData(model: VerifiedUrl, width: Int, height: Int, options: Options) =
+ ModelLoader.LoadData(ObjectKey(model), VerifiedUrlFetcher(model))
+
+ override fun handles(model: VerifiedUrl) = true
+
+ class Factory : ModelLoaderFactory {
+ override fun build(multiFactory: MultiModelLoaderFactory) = VerifiedUrlLoader()
+ override fun teardown() {}
+ }
+}
diff --git a/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt b/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt
index 357155071..1c39b0244 100644
--- a/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt
+++ b/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt
@@ -76,21 +76,24 @@ class PackageListAdapter(val fragment: Fragment) : RecyclerView.Adapter 0x404000
+ "x86_64" -> 0x410000
+ else -> throw IllegalStateException("unsupported ABI")
+}
+
// Returns a file descriptor that is backed by device storage, but not by any file.
// Its contents will be discarded automatically when it's closed, or when app process dies.
// This allows to avoid creating temporary files that would needlessly take up storage space if
-// app process is killed, OS crashes etc, and removes the problem of allocating unique temp file names
+// app process is killed, OS crashes etc., and removes the problem of allocating unique temp file names
@Throws(ErrnoException::class)
fun makeTemporaryFileDescriptor(): ScopedFileDescriptor {
- val fd: FileDescriptor
- val flags = O_RDWR or O_TRUNC or O_CREAT
+ val flags = O_RDWR or O_TMPFILE or O_EXCL
val mode = S_IRUSR or S_IWUSR
-
- val file: File = fileForTemporaryFileDescriptor
- val path = file.path
-
- // O_TMPFILE flag is not a part of Android SDK, though it is part of Android NDK. Its value is
- // ABI-dependent. Approximate O_TMPFILE with open() + unlink()
- synchronized(file) {
- fd = Os.open(path, flags, mode)
- // there's no wrapper for unlink()
- if (!file.delete()) {
- Os.close(fd)
- // should never happen
- throw ErrnoException("makeTemporaryFileDescriptor", EINVAL)
- }
- }
-
- return ScopedFileDescriptor(fd)
+ return ScopedFileDescriptor(Os.open(filesDir.path, flags, mode))
}
@Throws(ErrnoException::class)
diff --git a/app/src/main/java/app/grapheneos/apps/util/Utils.kt b/app/src/main/java/app/grapheneos/apps/util/Utils.kt
index 801c524dd..1cafeea26 100644
--- a/app/src/main/java/app/grapheneos/apps/util/Utils.kt
+++ b/app/src/main/java/app/grapheneos/apps/util/Utils.kt
@@ -2,32 +2,26 @@ package app.grapheneos.apps.util
import android.content.Context
import android.net.Uri
-import android.os.UserManager
import android.util.ArrayMap
-import android.view.MenuItem
-import android.view.View
-import android.view.inputmethod.InputMethodManager
-import android.widget.TextView
+import androidx.annotation.CheckResult
import androidx.annotation.StringRes
-import androidx.core.content.getSystemService
import app.grapheneos.apps.core.appContext
import app.grapheneos.apps.core.appResources
-import app.grapheneos.apps.core.isPrivilegedInstaller
import app.grapheneos.apps.core.mainHandler
import app.grapheneos.apps.core.mainThread
import app.grapheneos.apps.core.selfPkgName
-import app.grapheneos.apps.core.userManager
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import org.json.JSONArray
-import java.io.DataInputStream
-import java.io.DataOutputStream
+import java.io.FileDescriptor
+import java.io.FileInputStream
+import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
-import java.nio.charset.StandardCharsets.UTF_8
+import java.security.GeneralSecurityException
+import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicLong
-import kotlin.coroutines.coroutineContext
fun checkMainThread() {
check(Thread.currentThread() === mainThread)
@@ -38,24 +32,57 @@ fun checkNotMainThread() {
}
// a variant of kotlin.io.copyTo that checks for job cancellation and publishes progress updates
-fun InputStream.copyTo2(out: OutputStream, job: Job, progress: AtomicLong? = null, bufSize: Int = 16 * 1024): Long {
+@Throws(GeneralSecurityException::class, IOException::class)
+fun InputStream.copyTo2(numBytes: Long, out: OutputStream, job: Job?, debugInputName: String, progress: AtomicLong? = null, bufSize: Int = 16 * 1024) {
val buf = ByteArray(bufSize)
var total = 0L
while (true) {
- job.ensureActive()
+ job?.ensureActive()
val bufLen = this.read(buf)
- if (bufLen < 0) {
+ if (bufLen == -1) {
break
}
+ check(bufLen > 0)
- job.ensureActive()
+ if (total + bufLen > numBytes) {
+ throw GeneralSecurityException("copyTo2: input returned more data than expected (expected $numBytes bytes); input: $debugInputName")
+ }
+
+ job?.ensureActive()
out.write(buf, 0, bufLen)
total += bufLen
progress?.addAndGet(bufLen.toLong())
}
- return total
+ if (total != numBytes) {
+ throw IOException("copyTo2: input didn't return enough data (expected $numBytes bytes); input: $debugInputName")
+ }
+}
+
+@CheckResult
+fun checkSha256(fd: FileDescriptor, size: Long, required: ByteArray): Boolean {
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ val buf = ByteArray(DEFAULT_BUFFER_SIZE)
+ var totalRead = 0L
+ FileInputStream(fd).use {
+ while (true) {
+ val numRead = it.read(buf)
+ if (numRead == -1) {
+ if (totalRead != size) {
+ return false
+ }
+ break
+ }
+ check(numRead > 0)
+ totalRead += numRead
+ if (totalRead > size) {
+ return false
+ }
+ sha256.update(buf, 0, numRead)
+ }
+ }
+ return sha256.digest().contentEquals(required)
}
fun Boolean.toInt() = if (this) 1 else 0
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8765e0e21..ef44ed627 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -122,6 +122,7 @@
- this package is being installed in another user
- one or more of these packages are being installed in another user
+ delta update failed since the checksum of the installed app does not match the expected value. Retrying the app update will download the full app, which uses more data
Unable to connect to the server
Connection to the server has timed out
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 20b4c6cd8..5d2f70c4c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,19 +1,19 @@
[versions]
activityKtx = "1.13.0"
-agp = "9.2.0"
-appcompat = "1.7.1"
+agp = "9.4.1"
+appcompat = "1.8.0"
bcprovJdk18on = "1.86"
-constraintlayout = "2.2.1"
-coreKtx = "1.18.0"
-fragmentKtx = "1.8.9"
-glide = "5.0.7"
-kotlin = "2.3.21"
+constraintlayout = "2.2.2"
+coreKtx = "1.19.1"
+fragmentKtx = "1.9.1"
+glide = "5.0.9"
+kotlin = "2.4.21"
kotlinRetry = "2.0.2"
-kotlinxCoroutines = "1.10.2"
-ksp = "2.3.7"
-lifecycle = "2.10.0"
-material = "1.13.0"
-navigation = "2.9.8"
+kotlinxCoroutines = "1.11.0"
+ksp = "2.3.12"
+lifecycle = "2.11.0"
+material = "1.14.0"
+navigation = "2.10.2"
preferenceKtx = "1.2.1"
swiperefreshlayout = "1.2.0"
@@ -32,6 +32,7 @@ androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "s
bcprov-jdk18on = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bcprovJdk18on" }
glide-core = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" }
glide-ksp = { group = "com.github.bumptech.glide", name = "ksp", version.ref = "glide" }
+ina = { module = "app.accrescent.ina:Ina" }
kotlin-retry = { group = "com.michael-bull.kotlin-retry", name = "kotlin-retry", version.ref = "kotlinRetry" }
kotlin-retry-result = { group = "com.michael-bull.kotlin-retry", name = "kotlin-retry-result", version.ref = "kotlinRetry" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index d9ac90ca4..4e615ab03 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -198,6 +198,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -262,20 +276,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -359,9 +359,32 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -408,18 +431,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -444,18 +467,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -533,27 +556,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -568,6 +574,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -582,40 +616,31 @@
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
+
+
-
-
+
+
+
+
-
-
+
+
@@ -674,26 +699,43 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
@@ -701,27 +743,24 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
@@ -729,6 +768,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -797,6 +850,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -804,9 +871,6 @@
-
-
-
@@ -814,6 +878,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -927,9 +1008,6 @@
-
-
-
@@ -937,6 +1015,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1004,44 +1096,55 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
@@ -1217,18 +1320,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -1253,18 +1356,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -1342,10 +1445,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1408,34 +1528,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1450,18 +1542,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -1472,27 +1564,24 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
@@ -1500,6 +1589,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1544,18 +1647,18 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -1580,18 +1683,18 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -1627,6 +1730,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1682,46 +1799,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
+
@@ -1738,6 +1827,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1771,32 +1874,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
+
+
-
+
@@ -1814,9 +1903,6 @@
-
-
-
@@ -1824,6 +1910,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1868,55 +1971,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
-
-
-
@@ -1924,6 +1996,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1957,17 +2043,6 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -1985,6 +2060,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2018,38 +2110,21 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
@@ -2063,149 +2138,149 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
@@ -2383,23 +2458,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -2417,21 +2475,38 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -2467,18 +2542,18 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -2534,6 +2609,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2548,13 +2678,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
@@ -2562,6 +2703,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2672,170 +2841,219 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
+
-
-
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2845,18 +3063,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -2873,111 +3091,111 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -2988,89 +3206,89 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -3101,526 +3319,395 @@
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
@@ -3631,77 +3718,16 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -3716,20 +3742,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -3763,20 +3775,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -3809,9 +3807,6 @@
-
-
-
@@ -3827,11 +3822,6 @@
-
-
-
-
-
@@ -3852,20 +3842,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -3894,23 +3870,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -3927,35 +3889,35 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -3969,21 +3931,21 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -3997,21 +3959,21 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -4028,32 +3990,21 @@
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
+
+
-
-
+
+
@@ -4081,31 +4032,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4124,9 +4050,6 @@
-
-
-
@@ -4142,23 +4065,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
+
+
@@ -4171,16 +4089,6 @@
-
-
-
-
-
-
-
-
-
-
@@ -4196,9 +4104,9 @@
-
-
-
+
+
+
@@ -4215,20 +4123,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4254,39 +4148,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4294,27 +4155,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4322,9 +4166,6 @@
-
-
-
@@ -4332,6 +4173,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4342,36 +4200,21 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -4394,17 +4237,6 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -4412,9 +4244,6 @@
-
-
-
@@ -4462,11 +4291,6 @@
-
-
-
-
-
@@ -4477,16 +4301,6 @@
-
-
-
-
-
-
-
-
-
-
@@ -4509,17 +4323,6 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -4545,42 +4348,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4588,9 +4355,6 @@
-
-
-
@@ -4609,34 +4373,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4647,11 +4383,6 @@
-
-
-
-
-
@@ -4662,87 +4393,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4989,15 +4639,42 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -5058,643 +4735,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -5728,20 +4768,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -5756,9 +4782,18 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -5827,6 +4862,11 @@
+
+
+
+
+
@@ -5852,6 +4892,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -5910,6 +4995,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -5952,6 +5054,14 @@
+
+
+
+
+
+
+
+
@@ -6169,6 +5279,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -6183,32 +5318,32 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -6225,18 +5360,18 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -6250,23 +5385,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -6284,76 +5402,74 @@
-
-
-
+
+
+
-
-
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
-
-
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
-
+
+
-
-
-
-
+
+
@@ -6389,65 +5505,23 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -6492,29 +5566,153 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -6579,69 +5777,75 @@
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
+
+
-
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
-
+
-
-
+
+
-
-
+
+
@@ -6653,106 +5857,162 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -6766,46 +6026,88 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -6870,69 +6172,75 @@
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
+
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -6946,18 +6254,18 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -7022,69 +6330,75 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
@@ -7101,21 +6415,21 @@
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -7129,18 +6443,18 @@
-
-
+
+
-
+
-
-
+
+
-
-
+
+
@@ -7213,12 +6527,12 @@
-
-
-
+
+
+
-
-
+
+
@@ -7232,18 +6546,18 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -7257,46 +6571,57 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
@@ -7327,20 +6652,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -7352,6 +6663,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -7380,29 +6705,32 @@
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
+
+
@@ -7526,20 +6854,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -7558,37 +6872,17 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -7599,6 +6893,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -7677,6 +6988,9 @@
+
+
+
@@ -7688,9 +7002,6 @@
-
-
-
@@ -7721,23 +7032,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -7775,6 +7069,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -7795,24 +7123,24 @@
-
-
-
+
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -7835,6 +7163,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -7890,20 +7238,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -7911,14 +7245,17 @@
-
-
-
+
+
+
+
+
+
@@ -7926,6 +7263,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -7934,6 +7285,14 @@
+
+
+
+
+
+
+
+
@@ -7962,20 +7321,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -8062,20 +7407,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -8090,18 +7421,10 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -8112,6 +7435,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8123,46 +7460,46 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -8176,23 +7513,23 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
@@ -8233,17 +7570,6 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -8255,38 +7581,32 @@
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -8327,14 +7647,6 @@
-
-
-
-
-
-
-
-
@@ -8354,6 +7666,11 @@
+
+
+
+
+
@@ -8395,6 +7712,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -8429,9 +7757,6 @@
-
-
-
@@ -8473,9 +7798,6 @@
-
-
-
@@ -8489,6 +7811,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8534,6 +7873,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8551,11 +7907,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8567,6 +7956,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8584,6 +7990,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8617,6 +8065,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8625,6 +8089,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8639,6 +8154,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8688,11 +8220,6 @@
-
-
-
-
-
diff --git a/ina b/ina
new file mode 160000
index 000000000..d103e674e
--- /dev/null
+++ b/ina
@@ -0,0 +1 @@
+Subproject commit d103e674eee495a735c5203aaa882c88dacd0efb
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9bd254f61..1b4c4cabf 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -13,3 +13,8 @@ dependencyResolutionManagement {
}
rootProject.name = "AppStore"
include(":app")
+includeBuild("./ina") {
+ dependencySubstitution {
+ substitute(module("app.accrescent.ina:Ina")).using(project(":android"))
+ }
+}