mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
Add UpdateChecker
This commit is contained in:
parent
8ab092d2ca
commit
4d603d9d41
2 changed files with 375 additions and 2 deletions
62
README.md
62
README.md
|
|
@ -1,2 +1,60 @@
|
||||||
# Dolphin
|
# Dolphin - an OSS Android TV client for Jellyfin
|
||||||
An 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
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue