Add UpdateChecker

This commit is contained in:
Damontecres 2025-10-07 15:28:07 -04:00
parent 8ab092d2ca
commit 4d603d9d41
No known key found for this signature in database
2 changed files with 375 additions and 2 deletions

View file

@ -1,2 +1,60 @@
# Dolphin
An Android TV client for Jellyfin
# Dolphin - an OSS Android TV client for Jellyfin
This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a more "Plex app"-like app experience for users migrating from Plex to Jellyfin.
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). The user interface and controls have been written completely from scratch.
## Motivation
After using Plex and its Android TV app for 10+ years, I found the official Jellyfin client UI/UX to be a barrier for me using Jellyfin more, so if you wish the interface and playback controls were more like Plex's Android TV app, then Dolphin might work for you!
That said, Dolphin does not yet implement every feature in Jellyfin. It is a work in progress that will continue to improve over time.
## Distinguishing Features
- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app
- Show Movie/TV Show titles when browsing libraries
- Play TV Show theme music, if available
- "Plex app"-like playback controls, plus other enhancements, such as:
- Using D-Pad left/right for seeking during playback
- Subtly show playback position while seeking w/ D-Pad
- Quickly access video chapters during playback
- Setting to optionally skip back when resuming playback
## Installation
1. Enable side-loading "unknown" apps
- https://androidtvnews.com/unknown-sources-chromecast-google-tv/
- https://www.xda-developers.com/how-to-sideload-apps-android-tv/
- https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources
- https://www.aftvnews.com/how-to-enable-apps-from-unknown-sources-on-an-amazon-fire-tv-or-fire-tv-stick/
1. Install the APK on your Android TV device with one of these options:
- Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `TODO ENTER A VALUE HERE` or URL: https://aftv.news/TODOTODO
- Download the latest APK release from the [releases page](https://github.com/damontecres/StashAppAndroidTV/releases/latest) or https://aftv.news/745800
- Put the APK on an SD Card/USB stick/network share and use a file manager app from the Google Play Store / Amazon AppStore (e.g. `FX File Explorer`). Android's preinstalled file manager probably will not work!
- Use `Send files to TV` from the Google Play Store on your phone & TV
- (Expert) Use [ADB](https://developer.android.com/studio/command-line/adb) to install the APK from your computer ([guide](https://fossbytes.com/side-load-apps-android-tv/#h-how-to-sideload-apps-on-your-android-tv-using-adb))
### Upgrading the app
After the initial install above, the app will automatically check for updates which can then be installed in settings.
The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates.
## Compatibility
Dolphin requires Android 7.1+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` (tested on primarily `10.10.7`).
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
## Contributions
Issues and pull requests are always welcome! UI/UX improvements are especially desired!
Please check before submitting that your issue or pull request is not a duplicate.
If you plan to submit a pull request, please read the [contributing guide](CONTRIBUTING.md) before submitting!
## Additional screenshots
TODO

View file

@ -0,0 +1,315 @@
package com.github.damontecres.dolphin.util
import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.findActivity
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.io.File
import java.util.Date
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
class UpdateChecker(
private val context: Context,
private val okHttpClient: OkHttpClient,
) {
companion object {
// TODO apk names
private const val ASSET_NAME = "Dolphin.apk"
private const val DEBUG_ASSET_NAME = "Dolphin-debug.apk"
private const val RELEASE_ASSET_NAME = "Dolphin-release.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
}
suspend fun maybeShowUpdateToast(
updateUrl: String,
showNegativeToast: Boolean = false,
) {
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val now = Date()
val lastUpdateCheckThreshold =
pref
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
.hours
val lastUpdateCheck =
pref.getLong(
context.getString(R.string.pref_key_update_last_check),
0,
)
val timeSince = (now.time - lastUpdateCheck).milliseconds
Timber.v("Last successful update check was $timeSince ago")
val installedVersion = getInstalledVersion()
val latestRelease = getLatestRelease(updateUrl)
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
Timber.v("Update available $installedVersion => ${latestRelease.version}")
pref.edit {
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
}
if (lastUpdateCheckThreshold >= timeSince) {
Timber.i(
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
)
} else {
Toast
.makeText(
context,
"Update available: $installedVersion => ${latestRelease.version}!",
Toast.LENGTH_LONG,
).show()
}
} else {
Timber.v("No update available for $installedVersion")
if (showNegativeToast) {
Toast
.makeText(
context,
"No updates available, $installedVersion is the latest!",
Toast.LENGTH_LONG,
).show()
}
}
}
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.fromString(pkgInfo.versionName!!)
}
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
val preferredAsset =
if (PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
DEBUG_ASSET_NAME
}
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.firstOrNull { asset ->
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
listOf()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.w("Update version parsing failed. name=$name")
}
} else {
Timber.w("Update check failed: ${it.message}")
}
return@use null
}
}
}
suspend fun installRelease(release: Release) {
val activity = context.findActivity()!!
withContext(Dispatchers.IO) {
cleanup()
val request =
Request
.Builder()
.url(release.downloadUrl!!)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS,
)
}
val resolver = context.contentResolver
val uri =
resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
contentValues,
)
if (uri != null) {
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!)
}
}
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data = uri
context.startActivity(intent)
} else {
Timber.e("Resolver URI is null")
// TODO show error Toast
}
} else {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
),
PERMISSION_REQUEST_CODE,
)
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
it.body!!.byteStream().copyTo(output)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data =
FileProvider.getUriForFile(
activity,
activity.packageName + ".provider",
targetFile,
)
activity.startActivity(intent)
} else {
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
}
}
}
} else {
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
// TODO show error toast
}
}
}
}
/**
* Delete previously downloaded APKs
*/
fun cleanup() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
null,
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val displayName = cursor.getString(1)
Timber.v("id=$id, displayName=$displayName")
}
}
val deletedRows =
context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
)
Timber.i("Deleted $deletedRows rows")
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME)
if (targetFile.exists()) {
targetFile.delete()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception during cleanup")
}
}
}
@Serializable
data class Release(
val version: Version,
val downloadUrl: String?,
val publishedAt: String?,
val body: String?,
val notes: List<String>,
)