mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
parent
5a5a3a309b
commit
b046bc22f3
@ -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
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user