feat: Add manager for universal APK sharing

This commit introduces a comprehensive system for fetching, downloading, caching, and managing a "universal" APK of the app, intended for offline sharing with new users.

The core components are:
- `GitHubReleaseClient`: A new client to fetch the latest release information from the project's GitHub repository. It specifically looks for a universal APK asset in the release, parses its download URL, and attempts to extract its SHA256 checksum from the release notes.
- `UniversalApkManager`: Manages the entire lifecycle of the universal APK. It handles:
    - Checking for new versions by comparing the cached APK version against the latest GitHub release.
    - Downloading the APK with progress reporting.
    - Verifying the downloaded file against the SHA256 checksum, if available.
    - Caching the APK and its metadata (version, checksum, size) locally.
    - Cleaning up old APK versions to conserve space.
This commit is contained in:
Moe Hamade 2026-01-16 14:43:18 +02:00 committed by Moe Hamade
parent 5a5a3a309b
commit b046bc22f3
2 changed files with 571 additions and 0 deletions

View File

@ -0,0 +1,218 @@
package com.bitchat.android.util
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Client for fetching BitChat release information from GitHub API.
*/
object GitHubReleaseClient {
private const val TAG = "GitHubAPI"
private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest"
private const val USER_AGENT = "BitChat-Android"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Fetch the latest release information from GitHub.
* @return Release object with details, or null if fetch fails
*/
suspend fun fetchLatestRelease(): Release? = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Fetching latest release from GitHub API")
val request = Request.Builder()
.url(GITHUB_API_URL)
.addHeader("User-Agent", USER_AGENT)
.addHeader("Accept", "application/vnd.github.v3+json")
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "GitHub API request failed: ${response.code} ${response.message}")
return@withContext null
}
val body = response.body?.string()
if (body.isNullOrBlank()) {
Log.e(TAG, "Empty response body from GitHub API")
return@withContext null
}
parseRelease(body)
} catch (e: IOException) {
Log.e(TAG, "Network error fetching release", e)
null
} catch (e: Exception) {
Log.e(TAG, "Error fetching release", e)
null
}
}
/**
* Parse GitHub API JSON response into Release object.
*/
private fun parseRelease(jsonString: String): Release? {
try {
val json = JSONObject(jsonString)
val tagName = json.optString("tag_name", "")
val versionName = tagName.removePrefix("v") // Remove "v" prefix if present
if (versionName.isBlank()) {
Log.e(TAG, "No version tag found in release")
return null
}
Log.d(TAG, "Found release: $versionName")
// Parse assets array to find universal APK
val assets = json.optJSONArray("assets")
if (assets == null || assets.length() == 0) {
Log.e(TAG, "No assets found in release")
return null
}
// Look for universal APK (usually named "app-universal-release.apk")
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.optString("name", "")
if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) {
val downloadUrl = asset.optString("browser_download_url", "")
val size = asset.optLong("size", 0L)
if (downloadUrl.isBlank()) {
Log.e(TAG, "Universal APK found but no download URL")
continue
}
// Try to extract SHA256 from release body or notes
val body = json.optString("body", "")
val sha256 = extractSha256FromBody(body, name)
Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)")
return Release(
tagName = tagName,
versionName = versionName,
universalApkUrl = downloadUrl,
universalApkSha256 = sha256,
universalApkSize = size,
universalApkName = name
)
}
}
Log.e(TAG, "No universal APK found in release assets")
return null
} catch (e: Exception) {
Log.e(TAG, "Error parsing release JSON", e)
return null
}
}
/**
* Extract SHA256 checksum from release body/notes.
* Looks for patterns like:
* - sha256:abc123...
* - SHA256: abc123...
* - app-universal-release.apk: abc123...
*/
private fun extractSha256FromBody(body: String, apkName: String): String? {
if (body.isBlank()) return null
try {
// Pattern 1: Look for "sha256:" followed by hash
val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE)
sha256Pattern.find(body)?.let { match ->
return match.groupValues[1].lowercase()
}
// Pattern 2: Look for APK name followed by hash
val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE)
apkPattern.find(body)?.let { match ->
return match.groupValues[1].lowercase()
}
// Pattern 3: Look for any SHA256 hash (64 hex characters)
val hashPattern = Regex("""([a-fA-F0-9]{64})""")
val matches = hashPattern.findAll(body).toList()
// If we find exactly one hash, assume it's for the universal APK
if (matches.size == 1) {
return matches[0].groupValues[1].lowercase()
}
Log.w(TAG, "Could not extract SHA256 from release body")
return null
} catch (e: Exception) {
Log.w(TAG, "Error extracting SHA256", e)
return null
}
}
/**
* Check if a newer version is available.
* @param currentVersion Current installed/cached version
* @param latestRelease Latest release from GitHub
* @return true if latestRelease is newer
*/
fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean {
return try {
// Simple version comparison (assumes semantic versioning)
// Remove any non-numeric prefixes
val current = currentVersion.removePrefix("v").trim()
val latest = latestRelease.versionName.removePrefix("v").trim()
if (current == latest) {
return false
}
// Split by dots and compare each part
val currentParts = current.split(".").mapNotNull { it.toIntOrNull() }
val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() }
val maxLength = maxOf(currentParts.size, latestParts.size)
for (i in 0 until maxLength) {
val currentPart = currentParts.getOrNull(i) ?: 0
val latestPart = latestParts.getOrNull(i) ?: 0
if (latestPart > currentPart) {
return true
} else if (latestPart < currentPart) {
return false
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Error comparing versions", e)
false
}
}
/**
* Release information from GitHub.
*/
data class Release(
val tagName: String,
val versionName: String,
val universalApkUrl: String,
val universalApkSha256: String?,
val universalApkSize: Long,
val universalApkName: String
)
}

View File

@ -0,0 +1,353 @@
package com.bitchat.android.util
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/**
* Manages downloading, caching, and verifying the universal APK for offline sharing.
*/
class UniversalApkManager(private val context: Context) {
companion object {
private const val TAG = "UniversalApk"
private const val CACHE_DIR_NAME = "universal_apk"
private const val METADATA_FILE_NAME = "universal_apk_info.json"
private const val APK_FILE_PREFIX = "bitchat-universal-"
// Download buffer size (128KB)
private const val BUFFER_SIZE = 128 * 1024
}
private val cacheDir: File = File(context.cacheDir, CACHE_DIR_NAME).apply {
if (!exists()) {
mkdirs()
}
}
private val metadataFile: File = File(cacheDir, METADATA_FILE_NAME)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
/**
* Get information about the cached universal APK, if it exists.
*/
fun getCachedApkInfo(): ApkInfo? {
return try {
if (!metadataFile.exists()) {
return null
}
val json = JSONObject(metadataFile.readText())
val version = json.optString("version", "")
val checksum = json.optString("checksum", "")
val downloadDate = json.optLong("downloadDate", 0L)
val size = json.optLong("size", 0L)
val fileName = json.optString("fileName", "")
if (version.isBlank() || fileName.isBlank()) {
return null
}
val apkFile = File(cacheDir, fileName)
if (!apkFile.exists()) {
Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}")
return null
}
ApkInfo(
version = version,
checksum = checksum,
downloadDate = downloadDate,
size = size,
file = apkFile
)
} catch (e: Exception) {
Log.e(TAG, "Error reading cached APK info", e)
null
}
}
/**
* Get the cached APK file, if it exists.
*/
fun getCachedApk(): File? {
return getCachedApkInfo()?.file
}
/**
* Check for updates from GitHub.
* @return UpdateStatus indicating if update is available, current version, etc.
*/
suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) {
try {
val cachedInfo = getCachedApkInfo()
val latestRelease = GitHubReleaseClient.fetchLatestRelease()
if (latestRelease == null) {
return@withContext UpdateStatus.Error("Failed to fetch latest release from GitHub")
}
if (cachedInfo == null) {
// No cached APK
return@withContext UpdateStatus.NotDownloaded(latestRelease)
}
// Compare versions
val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease)
if (isNewer) {
UpdateStatus.UpdateAvailable(
currentVersion = cachedInfo.version,
latestRelease = latestRelease
)
} else {
UpdateStatus.UpToDate(cachedInfo.version)
}
} catch (e: Exception) {
Log.e(TAG, "Error checking for update", e)
UpdateStatus.Error(e.message ?: "Unknown error")
}
}
/**
* Download the universal APK from GitHub.
* @param progressCallback Called with progress percentage (0-100)
* @return Result with File on success, or error message
*/
suspend fun downloadUniversalApk(
progressCallback: ((Int) -> Unit)? = null
): Result<File> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Starting universal APK download")
// Fetch latest release info
val release = GitHubReleaseClient.fetchLatestRelease()
?: return@withContext Result.failure(Exception("Failed to fetch release info"))
val url = release.universalApkUrl
val expectedSize = release.universalApkSize
Log.d(TAG, "Downloading from: $url")
Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB")
// Download to temporary file first
val tempFile = File(cacheDir, "download_temp.apk")
if (tempFile.exists()) {
tempFile.delete()
}
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", "BitChat-Android")
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("Download failed: ${response.code} ${response.message}")
)
}
val body = response.body
?: return@withContext Result.failure(IOException("Empty response body"))
// Download with progress tracking
body.byteStream().use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = 0L
var lastProgress = 0
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
// Report progress
if (expectedSize > 0) {
val progress = ((totalBytesRead * 100) / expectedSize).toInt()
if (progress != lastProgress) {
lastProgress = progress
progressCallback?.invoke(progress)
}
}
}
Log.d(TAG, "Download complete: ${totalBytesRead / 1024 / 1024}MB")
}
}
// Verify checksum if available
if (release.universalApkSha256 != null) {
Log.d(TAG, "Verifying checksum...")
val isValid = verifyChecksum(tempFile, release.universalApkSha256)
if (!isValid) {
tempFile.delete()
return@withContext Result.failure(
Exception("Checksum verification failed. Downloaded file may be corrupted.")
)
}
Log.d(TAG, "Checksum verified successfully")
} else {
Log.w(TAG, "No checksum available for verification")
}
// Move to final location
val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk"
val finalFile = File(cacheDir, finalFileName)
// Clean up old APK files
cleanupOldApks()
// Move temp file to final location
if (finalFile.exists()) {
finalFile.delete()
}
tempFile.renameTo(finalFile)
// Save metadata
saveMetadata(
version = release.versionName,
checksum = release.universalApkSha256 ?: "",
size = finalFile.length(),
fileName = finalFileName
)
Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}")
Result.success(finalFile)
} catch (e: IOException) {
Log.e(TAG, "Network error downloading APK", e)
Result.failure(e)
} catch (e: Exception) {
Log.e(TAG, "Error downloading APK", e)
Result.failure(e)
}
}
/**
* Verify the SHA256 checksum of a file.
*/
suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) {
try {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
val checksum = digest.digest().joinToString("") { "%02x".format(it) }
val matches = checksum.equals(expectedSha256, ignoreCase = true)
if (!matches) {
Log.e(TAG, "Checksum mismatch!")
Log.e(TAG, "Expected: $expectedSha256")
Log.e(TAG, "Actual: $checksum")
}
matches
} catch (e: Exception) {
Log.e(TAG, "Error verifying checksum", e)
false
}
}
/**
* Delete the cached universal APK.
*/
fun deleteCachedApk(): Boolean {
return try {
val info = getCachedApkInfo()
if (info != null) {
info.file.delete()
metadataFile.delete()
Log.d(TAG, "Deleted cached APK: ${info.version}")
true
} else {
Log.w(TAG, "No cached APK to delete")
false
}
} catch (e: Exception) {
Log.e(TAG, "Error deleting cached APK", e)
false
}
}
/**
* Clean up old APK files (keep only the current one).
*/
private fun cleanupOldApks() {
try {
cacheDir.listFiles()?.forEach { file ->
if (file.name.startsWith(APK_FILE_PREFIX) && file.name.endsWith(".apk")) {
file.delete()
Log.d(TAG, "Cleaned up old APK: ${file.name}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error cleaning up old APKs", e)
}
}
/**
* Save metadata about the downloaded APK.
*/
private fun saveMetadata(version: String, checksum: String, size: Long, fileName: String) {
try {
val json = JSONObject().apply {
put("version", version)
put("checksum", checksum)
put("downloadDate", System.currentTimeMillis())
put("size", size)
put("fileName", fileName)
}
metadataFile.writeText(json.toString())
Log.d(TAG, "Saved metadata: $version")
} catch (e: Exception) {
Log.e(TAG, "Error saving metadata", e)
}
}
/**
* Information about a cached APK.
*/
data class ApkInfo(
val version: String,
val checksum: String,
val downloadDate: Long,
val size: Long,
val file: File
)
/**
* Update check status.
*/
sealed class UpdateStatus {
data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus()
data class UpToDate(val currentVersion: String) : UpdateStatus()
data class UpdateAvailable(
val currentVersion: String,
val latestRelease: GitHubReleaseClient.Release
) : UpdateStatus()
data class Error(val message: String) : UpdateStatus()
}
}