From b046bc22f32864f274513d40fdb359dca5fd0415 Mon Sep 17 00:00:00 2001 From: Moe Hamade Date: Fri, 16 Jan 2026 14:43:18 +0200 Subject: [PATCH] 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. --- .../android/util/GitHubReleaseClient.kt | 218 +++++++++++ .../android/util/UniversalApkManager.kt | 353 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt new file mode 100644 index 00000000..e157b65c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt new file mode 100644 index 00000000..b14acaaf --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -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 = 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() + } +}