Skip to content
Open
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
12 changes: 11 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>

<uses-feature
android:name="android.hardware.touchscreen"
Expand Down Expand Up @@ -34,6 +35,15 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.update_files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/update_file_paths"/>
</provider>
</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode

private const val DialogScrimAlpha = 0.62f

Expand Down Expand Up @@ -39,7 +40,9 @@ internal fun TvDialogOverlay(
latestFocusRestorer.value?.onDialogClosed()
}
}
BackHandler(onBack = dismiss)
if (!LocalInspectionMode.current) {
BackHandler(onBack = dismiss)
}
Box(
modifier = modifier
.fillMaxSize()
Expand Down
114 changes: 113 additions & 1 deletion app/src/main/java/com/kino/puber/data/api/KinoPubApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.kino.puber.data.api.models.DeviceResponse
import com.kino.puber.data.api.models.DeviceSettings
import com.kino.puber.data.api.models.Episode
import com.kino.puber.data.api.models.Genre
import com.kino.puber.data.api.models.GitHubReleaseResponse
import com.kino.puber.data.api.models.History
import com.kino.puber.data.api.models.Item
import com.kino.puber.data.api.models.ItemFiles
Expand Down Expand Up @@ -63,13 +64,20 @@ import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.OkHttpClient
Expand Down Expand Up @@ -108,7 +116,7 @@ class KinoPubApiClient(
BearerTokens(accessToken.orEmpty(), refreshToken.orEmpty())
}

sendWithoutRequest { true }
sendWithoutRequest { request -> shouldSendKinoPubToken(request.url.host) }

refreshTokens {
val refreshToken = cryptoPreferenceRepository.getRefreshToken().orEmpty()
Expand Down Expand Up @@ -823,13 +831,111 @@ class KinoPubApiClient(
httpClient.get("${KinoPubConfig.EXTRA_API_BASE_URL}api2/v1.1/items/collections/$itemId")
}

// App update API

suspend fun getLatestGitHubRelease(owner: String, repo: String): Result<GitHubReleaseResponse> = apiCall {
httpClient.get("https://api.github.com/repos/$owner/$repo/releases/latest") {
headers {
append("Accept", "application/vnd.github+json")
}
}
}

suspend fun downloadUpdateAsset(
url: String,
targetFile: File,
onProgress: (Int) -> Unit,
): Result<File> = withContext(Dispatchers.IO) {
val tempFile = File("${targetFile.absolutePath}.download")
try {
targetFile.parentFile?.mkdirs()
if (tempFile.exists() && !tempFile.delete()) {
throw IllegalStateException("Unable to delete stale update download")
}

val response = httpClient.get(url)
if (!response.status.isSuccess()) {
throw IllegalStateException("Update download failed with HTTP ${response.status.value}")
}

val totalBytes = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
val channel = response.bodyAsChannel()
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var downloadedBytes = 0L
var lastProgress = -1

fun dispatchProgress(percent: Int) {
val coercedPercent = percent.coerceIn(0, 100)
if (coercedPercent != lastProgress) {
lastProgress = coercedPercent
onProgress(coercedPercent)
}
}

if (totalBytes != null && totalBytes > 0L) {
dispatchProgress(0)
}

tempFile.outputStream().buffered().use { output ->
while (!channel.isClosedForRead) {
val bytesRead = channel.readAvailable(buffer)
if (bytesRead == -1) {
break
}

output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead

if (totalBytes != null && totalBytes > 0L) {
dispatchProgress(((downloadedBytes * 100L) / totalBytes).toInt())
}
}
}

if (!tempFile.renameTo(targetFile)) {
if (targetFile.exists() && !targetFile.delete()) {
throw IllegalStateException("Unable to replace existing update download")
}
if (!tempFile.renameTo(targetFile)) {
throw IllegalStateException("Unable to finalize update download")
}
}

dispatchProgress(100)
Result.success(targetFile)
} catch (error: CancellationException) {
tempFile.delete()
throw error
} catch (error: Exception) {
tempFile.delete()
Result.failure(error)
}
}

suspend fun getUpdateChecksum(url: String): Result<String> = withContext(Dispatchers.IO) {
try {
val response = httpClient.get(url)
if (!response.status.isSuccess()) {
throw IllegalStateException("Update checksum download failed with HTTP ${response.status.value}")
}

Result.success(response.bodyAsText())
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Result.failure(error)
}
}

// Helper method for API calls

suspend inline fun <reified T> apiCall(
block: suspend () -> HttpResponse
): Result<T> = try {
val response = block()
Result.success(response.body<T>())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
Expand Down Expand Up @@ -967,10 +1073,16 @@ class KinoPubApiClient(
}
}

private fun shouldSendKinoPubToken(host: String): Boolean {
return host == Url(KinoPubConfig.MAIN_API_BASE_URL).host ||
host == Url(KinoPubConfig.OAUTH_BASE_URL).host
}

companion object {
private const val MAX_RETRIES = 3
private const val CONNECT_TIMEOUT = 60_000L
private const val READ_TIMEOUT = 120_000L
private const val CACHE_SIZE = 50L * 1024 * 1024 // 50 MB
private const val DOWNLOAD_BUFFER_SIZE = 8 * 1024
}
}
41 changes: 37 additions & 4 deletions app/src/main/java/com/kino/puber/data/api/KtorPlugins.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.request
import io.ktor.content.TextContent
import io.ktor.http.HttpHeaders
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.availableForRead
import io.ktor.utils.io.core.build
Expand Down Expand Up @@ -81,16 +82,29 @@ val CurlLogger = createClientPlugin("CurlLogger") {
}

onResponse { response ->
val responseBody = response.bodyAsChannel()
val content = readTextLimited(responseBody, maxBodySize)

log("<-- ${response.status} ${response.request.url}")
response.headers.entries().forEach { (key, values) ->
values.forEach { value ->
log("$key: $value")
}
}

if (
shouldSkipBodyLogging(
host = response.request.url.host,
path = response.request.url.encodedPath,
contentType = response.headers[HttpHeaders.ContentType],
)
) {
log("")
log("<body logging skipped>")
log("<-- END HTTP")
return@onResponse
}

val responseBody = response.bodyAsChannel()
val content = readTextLimited(responseBody, maxBodySize)

log("")
log(content)
log("<-- END HTTP (${content.length}-char body)")
Expand All @@ -116,4 +130,23 @@ private suspend fun readTextLimited(channel: ByteReadChannel, maxSize: Long): St
} catch (_: Exception) {
"<binary body or decode error>"
}
}
}

private fun shouldSkipBodyLogging(host: String, path: String, contentType: String?): Boolean {
val normalizedHost = host.lowercase()
val normalizedPath = path.lowercase()
val normalizedContentType = contentType.orEmpty().lowercase()

return normalizedHost.isGitHubHost() ||
normalizedPath.endsWith(".apk") ||
normalizedPath.endsWith(".sha256") ||
normalizedContentType.contains("application/vnd.android.package-archive") ||
normalizedContentType.startsWith("application/octet-stream")
}

private fun String.isGitHubHost(): Boolean {
return this == "github.com" ||
endsWith(".github.com") ||
this == "githubusercontent.com" ||
endsWith(".githubusercontent.com")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.kino.puber.data.api.models

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class GitHubReleaseResponse(
@SerialName("tag_name") val tagName: String = "",
val name: String? = null,
val body: String? = null,
@SerialName("html_url") val htmlUrl: String? = null,
val draft: Boolean = false,
val prerelease: Boolean = false,
val assets: List<GitHubReleaseAssetResponse> = emptyList(),
)

@Serializable
data class GitHubReleaseAssetResponse(
val name: String = "",
@SerialName("browser_download_url") val browserDownloadUrl: String = "",
val size: Long? = null,
@SerialName("content_type") val contentType: String? = null,
)
9 changes: 9 additions & 0 deletions app/src/main/java/com/kino/puber/data/di/modules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ package com.kino.puber.data.di
import android.net.ConnectivityManager
import com.kino.puber.core.session.SessionEventBus
import com.kino.puber.data.api.KinoPubApiClient
import com.kino.puber.data.repository.AppUpdateDownloader
import com.kino.puber.data.repository.AppUpdateInstaller
import com.kino.puber.data.repository.AppUpdatePreferencesRepository
import com.kino.puber.data.repository.AppUpdateRepository
import com.kino.puber.data.repository.CryptoPreferenceRepository
import com.kino.puber.data.repository.DeviceInfoRepository
import com.kino.puber.data.repository.DeviceSettingsRepository
import com.kino.puber.data.repository.IAppUpdateRepository
import com.kino.puber.data.repository.ICryptoPreferenceRepository
import com.kino.puber.data.repository.IDeviceInfoRepository
import com.kino.puber.data.repository.IDeviceSettingsRepository
Expand Down Expand Up @@ -58,6 +63,10 @@ private const val MIB = 1024L * 1024L
private const val MEDIA_CACHE_SIZE_BYTES = 512L * MIB

val repositoryModule = module {
singleOf(::AppUpdateRepository) { bind<IAppUpdateRepository>() }
singleOf(::AppUpdatePreferencesRepository)
singleOf(::AppUpdateInstaller)
singleOf(::AppUpdateDownloader)
singleOf(::KinoPubRepository) { bind<IKinoPubRepository>() }
singleOf(::CryptoPreferenceRepository) { bind<ICryptoPreferenceRepository>() }
singleOf(::DeviceInfoRepository) { bind<IDeviceInfoRepository>() }
Expand Down
Loading
Loading