diff --git a/README.md b/README.md index af28e0a3..10d2f863 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt new file mode 100644 index 00000000..f0234568 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt @@ -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("") + } + + 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, +)