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
16 changes: 16 additions & 0 deletions .github/workflows/android-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,28 @@ jobs:
sha256sum -c "dev.typetype.android_${RELEASE_CODE}.apk.sha256"
)

- name: Prepare beta update manifest
run: |
apk_name="dev.typetype.android_"$RELEASE_CODE".apk"
apk_sha256="$(cut -d ' ' -f1 "artifacts/$apk_name.sha256")"
jq -n \
--arg channel beta \
--arg packageName dev.typetype.android \
--arg versionName "$RELEASE_VERSION" \
--argjson versionCode "$RELEASE_CODE" \
--arg apkAsset "$apk_name" \
--arg sha256 "$apk_sha256" \
'{schemaVersion:1,channel:$channel,packageName:$packageName,versionName:$versionName,versionCode:$versionCode,apkAsset:$apkAsset,sha256:$sha256}' \
> artifacts/typetype-update.json

- name: Upload beta APK
uses: actions/upload-artifact@v7.0.1
with:
name: typetype-beta-apk
path: |
artifacts/*.apk
artifacts/*.sha256
artifacts/typetype-update.json
if-no-files-found: error
compression-level: 0
retention-days: 30
Expand Down Expand Up @@ -208,4 +223,5 @@ jobs:
files: |
artifacts/*.apk
artifacts/*.sha256
artifacts/typetype-update.json
fail_on_unmatched_files: true
19 changes: 19 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,14 @@ jobs:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
version="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts)"
version_code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)/\1/p' app/build.gradle.kts)"
test -n "$version"
[[ "$version_code" =~ ^[1-9][0-9]*$ ]]
expected_tag="v$version"
release_tag="${INPUT_TAG_NAME:-$expected_tag}"
test "$release_tag" = "$expected_tag"
echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV"
echo "RELEASE_CODE=$version_code" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"

- name: Prepare signing
Expand Down Expand Up @@ -215,13 +218,27 @@ jobs:
test -n "$build_tools"
"$build_tools/zipalign" -c -P 16 -v 4 "$apk"
"$build_tools/apksigner" verify --verbose --print-certs "$apk"
apk_application_id="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest application-id "$apk")"
apk_version="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-name "$apk")"
apk_code="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-code "$apk")"
test "$apk_application_id" = "dev.typetype.android"
test "$apk_version" = "$RELEASE_VERSION"
test "$apk_code" = "$RELEASE_CODE"
cp "$apk" "artifacts/TypeType-$RELEASE_TAG.apk"
(
cd artifacts
sha256sum "TypeType-$RELEASE_TAG.apk" \
> "TypeType-$RELEASE_TAG.apk.sha256"
apk_sha256="$(cut -d ' ' -f1 "TypeType-$RELEASE_TAG.apk.sha256")"
jq -n \
--arg channel stable \
--arg packageName dev.typetype.android \
--arg versionName "$RELEASE_VERSION" \
--argjson versionCode "$RELEASE_CODE" \
--arg apkAsset "TypeType-$RELEASE_TAG.apk" \
--arg sha256 "$apk_sha256" \
'{schemaVersion:1,channel:$channel,packageName:$packageName,versionName:$versionName,versionCode:$versionCode,apkAsset:$apkAsset,sha256:$sha256}' \
> typetype-update.json
)

- name: Upload release APK
Expand All @@ -231,6 +248,7 @@ jobs:
path: |
artifacts/*.apk
artifacts/*.sha256
artifacts/typetype-update.json
if-no-files-found: error
compression-level: 0
retention-days: 30
Expand All @@ -244,4 +262,5 @@ jobs:
files: |
artifacts/*.apk
artifacts/*.sha256
artifacts/typetype-update.json
fail_on_unmatched_files: true
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {
applicationId = "dev.typetype.android"
minSdk = 23
targetSdk = 37
versionCode = 11004
versionName = "1.10.0-beta.4"
versionCode = 11005
versionName = "1.10.1-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<queries>
<intent>
Expand Down Expand Up @@ -43,6 +44,16 @@
tools:node="remove" />
</provider>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updates"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/app_update_paths" />
</provider>

<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden"
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/dev/typetype/android/core/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,15 @@ object NetworkModule {
.build()
}

@Provides
@Singleton
@Named("appUpdates")
fun provideAppUpdatesOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(5, TimeUnit.MINUTES)
.retryOnConnectionFailure(true)
.build()
}
20 changes: 20 additions & 0 deletions app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.typetype.android.data.actions.VideoActionsRepositoryImpl
import dev.typetype.android.data.account.RoomAccountRepository
import dev.typetype.android.data.appupdate.AppUpdateDownloader
import dev.typetype.android.data.appupdate.DataStoreAppUpdateSettingsRepository
import dev.typetype.android.data.appupdate.GitHubAppUpdateRepository
import dev.typetype.android.data.account.AccountScopeProvider
import dev.typetype.android.data.account.ActiveAccountScope
import dev.typetype.android.data.auth.AuthRepositoryImpl
Expand Down Expand Up @@ -59,6 +62,9 @@ import dev.typetype.android.data.youtubesession.RemoteYoutubeSessionRepository
import dev.typetype.android.data.youtubesession.OkHttpYoutubeRemoteBrowserConnector
import dev.typetype.android.domain.actions.VideoActionsRepository
import dev.typetype.android.domain.account.AccountRepository
import dev.typetype.android.domain.appupdate.AppUpdateFileDownloader
import dev.typetype.android.domain.appupdate.AppUpdateRepository
import dev.typetype.android.domain.appupdate.AppUpdateSettingsRepository
import dev.typetype.android.domain.auth.AuthRepository
import dev.typetype.android.domain.branding.DeArrowRepository
import dev.typetype.android.domain.channel.ChannelRepository
Expand Down Expand Up @@ -107,6 +113,20 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

@Binds
@Singleton
abstract fun bindAppUpdateFileDownloader(impl: AppUpdateDownloader): AppUpdateFileDownloader

@Binds
@Singleton
abstract fun bindAppUpdateRepository(impl: GitHubAppUpdateRepository): AppUpdateRepository

@Binds
@Singleton
abstract fun bindAppUpdateSettingsRepository(
impl: DataStoreAppUpdateSettingsRepository,
): AppUpdateSettingsRepository

@Binds
@Singleton
abstract fun bindPlaybackQueueController(impl: PlaybackQueueCoordinator): PlaybackQueueController
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package dev.typetype.android.data.appupdate

import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.typetype.android.domain.appupdate.AppUpdateFailure
import dev.typetype.android.domain.appupdate.AppUpdateFailureReason
import dev.typetype.android.domain.appupdate.AppUpdateRelease
import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton

sealed interface AppUpdateInstallLaunch {
data object InstallerOpened : AppUpdateInstallLaunch
data object PermissionRequested : AppUpdateInstallLaunch
}

@Singleton
class AndroidAppUpdateInstaller @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun validate(file: File, release: AppUpdateRelease) {
if (!file.isFile || file.length() != release.apkSizeBytes || file.length() < APK_HEADER_SIZE) {
throw AppUpdateFailure(AppUpdateFailureReason.INVALID_PACKAGE)
}
val currentPackage = context.packageName
if (currentPackage != EXPECTED_PACKAGE_NAME) {
throw AppUpdateFailure(AppUpdateFailureReason.INVALID_PACKAGE)
}
val packageManager = context.packageManager
val archive = packageManager.getPackageArchiveInfo(file.absolutePath, packageInfoFlags())
?: throw AppUpdateFailure(AppUpdateFailureReason.INVALID_PACKAGE)
if (archive.packageName != EXPECTED_PACKAGE_NAME || archive.versionName != release.versionName) {
throw AppUpdateFailure(AppUpdateFailureReason.INVALID_PACKAGE)
}
val archiveCode = archive.versionCodeLong()
val installed = packageManager.getPackageInfo(currentPackage, packageInfoFlags())
if (archiveCode <= installed.versionCodeLong()) {
throw AppUpdateFailure(AppUpdateFailureReason.DOWNGRADE_BLOCKED)
}
if (release.versionCode?.let { it != archiveCode } == true) {
throw AppUpdateFailure(AppUpdateFailureReason.INVALID_PACKAGE)
}
if (!signingMatches(packageManager, installed, archive)) {
throw AppUpdateFailure(AppUpdateFailureReason.SIGNATURE_MISMATCH)
}
}

fun launch(file: File): AppUpdateInstallLaunch {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
context.startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + context.packageName))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
return AppUpdateInstallLaunch.PermissionRequested
}
val apkUri = FileProvider.getUriForFile(
context,
context.packageName + ".updates",
file,
)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(apkUri, APK_MIME_TYPE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
return AppUpdateInstallLaunch.InstallerOpened
}

private fun packageInfoFlags(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_SIGNATURES
}

private fun signingMatches(
packageManager: PackageManager,
installed: PackageInfo,
archive: PackageInfo,
): Boolean {
val archiveSigners = signerDigests(archive)
if (archiveSigners.isEmpty()) return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return archiveSigners.all { digest ->
val signerBytes = signerBytes(archive).firstOrNull { sha256(it) == digest }
signerBytes != null && packageManager.hasSigningCertificate(
context.packageName,
signerBytes,
PackageManager.CERT_INPUT_RAW_X509,
)
}
}
return archiveSigners == signerDigests(installed)
}

private fun signerDigests(info: PackageInfo): Set<String> =
signerBytes(info).mapTo(mutableSetOf(), ::sha256)

private fun signerBytes(info: PackageInfo): List<ByteArray> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners?.map { it.toByteArray() }.orEmpty()
} else {
@Suppress("DEPRECATION")
info.signatures?.map { it.toByteArray() }.orEmpty()
}

private fun sha256(value: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(value).joinToString("") { "%02x".format(it) }

@Suppress("DEPRECATION")
private fun PackageInfo.versionCodeLong(): Long =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) longVersionCode else versionCode.toLong()

private companion object {
const val EXPECTED_PACKAGE_NAME = "dev.typetype.android"
const val APK_MIME_TYPE = "application/vnd.android.package-archive"
const val APK_HEADER_SIZE = 4L
}
}
Loading