diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index e46a26028..846c20852 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -11,30 +11,16 @@ defaults:
# github does not appreciate ~ as home indicator. Prefer the full path.
working-directory: /home/runner/work/unchained-android/unchained-android/app
jobs:
- lint-debug:
- name: Run Debug Linter
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v2
- - name: Run tests
- run: ./gradlew lintDebug
- - name: Save lint reports
- uses: actions/upload-artifact@v2
- # artifacts are zipped together in the end
- with:
- name: lint-debug-report
- path: /home/runner/work/unchained-android/unchained-android/app/app/build/reports/
lint-release:
name: Run Release Linter
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- name: Run tests
run: ./gradlew lintRelease
- name: Save lint reports
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
# artifacts are zipped together in the end
with:
name: lint-release-report
@@ -43,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- name: Retrieve keystore for apk signing
env:
ENCODED_KEYSTORE: ${{ secrets.KEYSTORE }}
@@ -58,7 +44,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew clean assembleRelease --stacktrace
- name: Save apk
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
with:
name: apk
path: /home/runner/work/unchained-android/unchained-android/app/app/build/outputs/apk/release/*.apk
@@ -66,7 +52,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- name: Retrieve keystore for apk signing
env:
ENCODED_KEYSTORE: ${{ secrets.KEYSTORE }}
@@ -81,7 +67,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew clean assembleDebug --stacktrace
- name: Save apk
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
with:
name: apk-debug
path: /home/runner/work/unchained-android/unchained-android/app/app/build/outputs/apk/debug/*.apk
diff --git a/README.md b/README.md
index 589992fa1..966c3ee7a 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,12 @@
# Unchained for Android
-[![License](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Kotlin Version](https://img.shields.io/badge/kotlin-1.5.30-blue)](http://kotlinlang.org/) [![API](https://img.shields.io/badge/API-22%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=22) [![Build Status](https://img.shields.io/github/workflow/status/LivingWithHippos/unchained-android/Build)](https://github.com/LivingWithHippos/unchained-android/actions) [![Play Store](https://img.shields.io/badge/play%20store-available-brightgreen)](https://play.google.com/store/apps/details?id=com.github.livingwithhippos.unchained) [![translated](https://localization.professiona.li/widgets/unchained-for-android/-/strings/svg-badge.svg)](https://localization.professiona.li/engage/unchained-for-android/)
+[![License](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![API](https://img.shields.io/badge/API-22%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=22) [![Build Status](https://img.shields.io/github/workflow/status/LivingWithHippos/unchained-android/Build)](https://github.com/LivingWithHippos/unchained-android/actions) [![Play Store](https://img.shields.io/badge/play%20store-available-brightgreen)](https://play.google.com/store/apps/details?id=com.github.livingwithhippos.unchained) [![F Droid](https://img.shields.io/f-droid/v/com.github.livingwithhippos.unchained)](https://f-droid.org/packages/com.github.livingwithhippos.unchained/) [![translated](https://localization.professiona.li/widgets/unchained-for-android/-/strings/svg-badge.svg)](https://localization.professiona.li/engage/unchained-for-android/)
+
+
+
+
+
@@ -14,21 +19,21 @@ App to interact with [Real Debrid](https://real-debrid.com/) APIs.
Real Debrid is a service to download files from hosting websites and the torrent network.
Files are downloaded directly on their servers that you can then use for your downloads.
-They provide high speeds without premium accounts for a lot of services like Mega and RapidGator
-and can also stream media files directly. It offers a cheap premium service.
+They provide high speeds for a lot of services like Mega and RapidGator without needing
+all their premium accounts, and can also stream media files directly.
+**N.B. Real Debrid is a (cheap) paid service**
### Features :memo:
You can take a look at the project [here](https://github.com/LivingWithHippos/unchained-android/projects/1) for general status.
-- [x] user info
-- [x] themes support
-- [x] hoster support
+- [x] magnets/torrents support
+- [x] file hosting services support
+- [x] streaming support (needs a player that supports streaming like mpv or VLC)
+- [x] search websites for files with plugins
- [x] containers support
-- [x] magnets support
-- [x] torrent support
-- [x] streaming support (needs a player that supports streaming like VLC)
-- [x] search magnet and torrents
+- [x] user info
+- [x] themes
### Screenshots :iphone:
@@ -48,6 +53,9 @@ You have multiple options to install Unchained for Android:
### Developing and Contributing :writing_hand:
+## [![Repography logo](https://images.repography.com/logo.svg)](https://repography.com) /
+[![Issue status graph](https://images.repography.com/28505435/LivingWithHippos/unchained-android/recent-activity/9be46c12746e55ef26535ea523c2bda5_issues.svg)](https://github.com/LivingWithHippos/unchained-android/issues)
+
Contributions are welcome. You can use the [discussion tab](https://github.com/LivingWithHippos/unchained-android/discussions) to ask for help setting up the project. At the moment at least Android Studio 2021.1.1 is needed to build the project.
@@ -108,6 +116,10 @@ LWeoBVVmaYAiZ3oGaLAV9sV2dvY62XxdCF
- DaisyF8
- Roadhouse
+#### Translators
+
+- edgarpatronperez (spanish)
+
#### Media
Logo and symbols inspired by [minimal logo design set](https://www.rawpixel.com/image/843352/minimal-logo-designs-set) offered by [rawpixel.com](https://www.rawpixel.com)
diff --git a/app/.run/Check dependencies updates.run.xml b/app/.run/Lint checking.run.xml
similarity index 72%
rename from app/.run/Check dependencies updates.run.xml
rename to app/.run/Lint checking.run.xml
index 04c14f91f..e361ff237 100644
--- a/app/.run/Check dependencies updates.run.xml
+++ b/app/.run/Lint checking.run.xml
@@ -1,19 +1,19 @@
-
+
-
+
-
+
-
+
true
true
diff --git a/app/.run/Lint format.run.xml b/app/.run/Lint format.run.xml
new file mode 100644
index 000000000..f78f99cab
--- /dev/null
+++ b/app/.run/Lint format.run.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/app/app/build.gradle b/app/app/build.gradle
index 5f6c30eb0..d4aa7304f 100644
--- a/app/app/build.gradle
+++ b/app/app/build.gradle
@@ -4,6 +4,8 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: 'com.google.protobuf'
+apply plugin: 'com.mikepenz.aboutlibraries.plugin'
+apply plugin: 'kotlin-parcelize'
protobuf {
protoc {
@@ -35,14 +37,14 @@ if (apiPropertiesFile.exists()) {
android {
- compileSdk 32
+ compileSdk 33
defaultConfig {
applicationId "com.github.livingwithhippos.unchained"
minSdk 22
- targetSdk 32
- versionCode 32
- versionName "4.40.2-beta"
+ targetSdk 33
+ versionCode 33
+ versionName "4.51.4-beta"
// limit resources for a list of locales
// resConfigs "en", "it"
@@ -242,6 +244,7 @@ dependencies {
//hilt
implementation deps.dagger.hilt_android
+ implementation deps.dagger.hilt_navigation
kapt deps.dagger.hilt_compiler
// paging
@@ -256,9 +259,16 @@ dependencies {
//jsoup
implementation deps.jsoup
+ // work manager
+ implementation deps.work.core
+
// countly
debugImplementation deps.countly
+ // about
+ implementation deps.aboutlibraries.core
+ implementation deps.aboutlibraries.ui
+
// test
androidTestImplementation deps.test.core_ktx
androidTestImplementation deps.test.rules
diff --git a/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/CustomViewActions.kt b/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/CustomViewActions.kt
index b860cbb6a..bd174e51d 100644
--- a/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/CustomViewActions.kt
+++ b/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/CustomViewActions.kt
@@ -51,4 +51,4 @@ fun waitForView(viewId: Int, timeout: Long): ViewAction {
.build()
}
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt b/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt
index f8e6ffb25..0ad105e9f 100644
--- a/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt
+++ b/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt
@@ -1,7 +1,6 @@
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
-import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
@@ -18,7 +17,6 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-
/**
* Test setup: run this once and then edit the generated configuration:
* 1. Add your private token as an Instrumentation argument called TOKEN (maybe don't use your main account)
diff --git a/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt b/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt
index e9f38139c..70edab3a6 100644
--- a/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt
+++ b/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt
@@ -36,4 +36,4 @@ object TelemetryManager {
Countly.sharedInstance().init(config)
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
index cc271e0a2..eae9134a1 100644
--- a/app/app/src/main/AndroidManifest.xml
+++ b/app/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
-
-
+
-
-
-
-
+
-
-
+
-
-
-
-
+
-
-
+
+
+
+
+
-
-
-
-
+
+
+
-
-
+
+
+
+
+
-
-
-
-
+
-
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt
index eea65513c..25347699b 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt
@@ -157,8 +157,6 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener {
activityViewModel.updateCredentialsAccessToken(token.accessToken)
activityViewModel.updateCredentialsRefreshToken(token.refreshToken)
activityViewModel.transitionAuthenticationMachine(FSMAuthenticationEvent.OnOpenTokenLoaded)
-
-
}
}
)
@@ -214,7 +212,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener {
}
override fun onOpenLinkClick(url: String) {
- openExternalWebPage(url)
+ context?.openExternalWebPage(url)
}
companion object {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt
index b754faca7..8269dd4c1 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt
@@ -61,7 +61,6 @@ class AuthenticationViewModel @Inject constructor(
}
}
}
-
}
fun fetchToken() {
@@ -106,4 +105,4 @@ sealed class SecretResult {
object Empty : SecretResult()
object Expired : SecretResult()
data class Retrieved(val value: Secrets) : SecretResult()
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt
index cef6c5111..6f81814d9 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt
@@ -1,5 +1,6 @@
package com.github.livingwithhippos.unchained.base
+import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.BroadcastReceiver
@@ -9,22 +10,31 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
+import android.content.pm.PackageManager
import android.content.res.Configuration
+import android.net.ConnectivityManager
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.view.Menu
+import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
+import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
+import androidx.core.view.MenuProvider
import androidx.core.view.forEach
-import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
+import androidx.navigation.ui.setupWithNavController
+import com.github.livingwithhippos.unchained.BuildConfig
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.data.model.UserAction
import com.github.livingwithhippos.unchained.data.repository.PluginRepository.Companion.TYPE_UNCHAINED
@@ -36,23 +46,28 @@ import com.github.livingwithhippos.unchained.settings.view.SettingsFragment.Comp
import com.github.livingwithhippos.unchained.start.viewmodel.MainActivityMessage
import com.github.livingwithhippos.unchained.start.viewmodel.MainActivityViewModel
import com.github.livingwithhippos.unchained.statemachine.authentication.CurrentFSMAuthentication
-import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationEvent
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationState
+import com.github.livingwithhippos.unchained.utilities.APP_LINK
import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.EventObserver
+import com.github.livingwithhippos.unchained.utilities.PreferenceKeys
import com.github.livingwithhippos.unchained.utilities.SCHEME_HTTP
import com.github.livingwithhippos.unchained.utilities.SCHEME_HTTPS
import com.github.livingwithhippos.unchained.utilities.SCHEME_MAGNET
+import com.github.livingwithhippos.unchained.utilities.SIGNATURE
import com.github.livingwithhippos.unchained.utilities.TelemetryManager
-import com.github.livingwithhippos.unchained.utilities.extension.downloadFile
-import com.github.livingwithhippos.unchained.utilities.extension.isWebUrl
-import com.github.livingwithhippos.unchained.utilities.extension.setupWithNavController
+import com.github.livingwithhippos.unchained.utilities.extension.downloadFileInStandardFolder
+import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage
import com.github.livingwithhippos.unchained.utilities.extension.showToast
+import com.github.livingwithhippos.unchained.utilities.extension.toHex
import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.mikepenz.aboutlibraries.LibsBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
+import java.security.MessageDigest
import javax.inject.Inject
/**
@@ -62,6 +77,9 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
+ private lateinit var navController: NavController
+ private lateinit var appBarConfiguration: AppBarConfiguration
+
// countly crash reporter set up. Debug mode only
override fun onStart() {
super.onStart()
@@ -70,17 +88,59 @@ class MainActivity : AppCompatActivity() {
override fun onStop() {
TelemetryManager.onStop()
+ // todo: implement for TorrentService
+ // unbindService()
super.onStop()
}
+ @Suppress("DEPRECATION")
+ @SuppressLint("PackageManagerGetSignatures")
+ private fun getApplicationSignatures(packageName: String = getPackageName()): List {
+ val signatureList: List
+ try {
+ val digest = MessageDigest.getInstance("SHA")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // New signature
+ val sig = packageManager.getPackageInfo(
+ packageName,
+ PackageManager.GET_SIGNING_CERTIFICATES
+ ).signingInfo
+ signatureList = if (sig.hasMultipleSigners()) {
+ // Send all with apkContentsSigners
+ sig.apkContentsSigners.map {
+ digest.update(it.toByteArray())
+ digest.digest().toHex()
+ }
+ } else {
+ // Send one with signingCertificateHistory
+ sig.signingCertificateHistory.map {
+ digest.update(it.toByteArray())
+ digest.digest().toHex()
+ }
+ }
+ } else {
+ val sig = packageManager.getPackageInfo(
+ packageName,
+ PackageManager.GET_SIGNATURES
+ ).signatures
+ signatureList = sig.map {
+ digest.update(it.toByteArray())
+ digest.digest().toHex()
+ }
+ }
+
+ return signatureList
+ } catch (e: Exception) {
+ Timber.e("Error while getting package signatures: ${e.message}")
+ }
+ return emptyList()
+ }
+
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
TelemetryManager.onConfigurationChanged(newConfig)
}
- private var currentNavController: LiveData? = null
- private lateinit var appBarConfiguration: AppBarConfiguration
-
private lateinit var binding: ActivityMainBinding
val viewModel: MainActivityViewModel by viewModels()
@@ -105,30 +165,87 @@ class MainActivity : AppCompatActivity() {
}
}
+ private val requestDownloadPermissionLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ applicationContext.showToast(R.string.download_permission_granted)
+ } else {
+ applicationContext.showToast(R.string.needs_download_permission)
+ }
+ }
+
+
+ private val requestNotificationPermissionLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ applicationContext.showToast(R.string.notifications_permission_granted)
+ } else {
+ applicationContext.showToast(R.string.notifications_permission_denied)
+ }
+ }
+
+ private val pickDirectoryLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) {
+ if (it != null) {
+ Timber.d("User has picked a folder $it")
+
+ // permanent permissions
+ val contentResolver = applicationContext.contentResolver
+
+ val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+
+ contentResolver.takePersistableUriPermission(it, takeFlags)
+
+ viewModel.setDownloadFolder(it)
+
+ applicationContext.showToast(R.string.directory_picked)
+ } else {
+ Timber.d("User has not picked a folder")
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.topAppBar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- if (savedInstanceState == null) {
- setupBottomNavigationBar()
- } // Else, need to wait for onRestoreInstanceState
+ setupBottomNavigationBar(binding)
- // list of fragments with no back arrow
- appBarConfiguration = AppBarConfiguration(
- setOf(
- R.id.authentication_dest,
- R.id.start_dest,
- R.id.user_dest,
- R.id.list_tabs_dest,
- R.id.search_dest
- ),
- null
- )
+ addMenuProvider(object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ // Add menu items here
+ menuInflater.inflate(R.menu.top_app_bar, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.settings -> {
+ openSettings()
+ true
+ }
+ R.id.about -> {
+ LibsBuilder()
+ .withAboutAppName(getString(R.string.app_name))
+ .withAboutIconShown(true)
+ .withAboutVersionShown(true)
+ .withActivityTitle(getString(R.string.about))
+ .start(this@MainActivity)
+ true
+ }
+ else -> false
+ }
+ }
+ })
viewModel.fsmAuthenticationState.observe(
this
@@ -200,7 +317,7 @@ class MainActivity : AppCompatActivity() {
is FSMAuthenticationState.AuthenticatedPrivateToken, FSMAuthenticationState.AuthenticatedOpenToken -> {
// we probably stopped and restored the app, do the same actions
// in the viewModel.fsmAuthenticationState.observe for these states
-
+
// unlock the bottom menu
enableAllBottomNavItems()
}
@@ -275,19 +392,197 @@ class MainActivity : AppCompatActivity() {
) {
when (val content = it?.getContentIfNotHandled()) {
is MainActivityMessage.InstalledPlugins -> {
- currentToast.cancel()
- currentToast.setText(
- getString(
- R.string.n_plugins_installed,
- content.number
+ lifecycleScope.launch {
+ currentToast.cancel()
+ // calling cancel stops the toast from showing on api 22 maybe others
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
+ delay(200)
+ }
+ currentToast.setText(
+ getString(
+ R.string.n_plugins_installed,
+ content.number
+ )
)
- )
- currentToast.show()
+ currentToast.show()
+ }
}
is MainActivityMessage.StringID -> {
- currentToast.cancel()
- currentToast.setText(getString(content.id))
- currentToast.show()
+ lifecycleScope.launch {
+ currentToast.cancel()
+ // calling cancel stops the toast from showing on api 22 maybe others
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
+ delay(200)
+ }
+ currentToast.setText(getString(content.id))
+ currentToast.show()
+ }
+ }
+ is MainActivityMessage.UpdateFound -> {
+ when (content.signature) {
+ SIGNATURE.F_DROID -> {
+ showUpdateDialog(
+ getString(R.string.fdroid_update_description),
+ APP_LINK.F_DROID
+ )
+ }
+ SIGNATURE.PLAY_STORE -> {
+ showUpdateDialog(
+ getString(R.string.playstore_update_description),
+ APP_LINK.PLAY_STORE
+ )
+ }
+ SIGNATURE.GITHUB -> {
+ showUpdateDialog(
+ getString(R.string.github_update_description),
+ APP_LINK.GITHUB
+ )
+ }
+ else -> {}
+ }
+ }
+ MainActivityMessage.RequireDownloadFolder -> {
+ pickDirectoryLauncher.launch(null)
+ }
+ MainActivityMessage.RequireDownloadPermissions -> {
+ requestDownloadPermissionLauncher.launch(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ )
+ }
+ MainActivityMessage.RequireNotificationPermissions -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestNotificationPermissionLauncher.launch(
+ Manifest.permission.POST_NOTIFICATIONS
+ )
+ }
+ }
+ is MainActivityMessage.MultipleDownloadsEnqueued -> {
+
+ if (
+ Build.VERSION.SDK_INT in 23..28 &&
+ ContextCompat.checkSelfPermission(
+ applicationContext,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ ) != PERMISSION_GRANTED
+ ) {
+ viewModel.requireDownloadPermissions()
+ } else {
+
+ when (viewModel.getDownloadManagerPreference()) {
+ PreferenceKeys.DownloadManager.SYSTEM -> {
+ val manager =
+ applicationContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ var downloadsStarted = 0
+ content.downloads.forEach { download ->
+
+ val queuedDownload = manager.downloadFileInStandardFolder(
+ source = Uri.parse(download.download),
+ title = download.filename,
+ description = getString(R.string.app_name),
+ fileName = download.filename
+ )
+ when (queuedDownload) {
+ is EitherResult.Failure -> {
+ Timber.e("Error queuing ${download.link}: ${queuedDownload.failure.message}")
+ }
+ is EitherResult.Success -> {
+ downloadsStarted++
+ }
+ }
+ }
+
+ applicationContext?.showToast(
+ getString(
+ R.string.multiple_downloads_enqueued_format,
+ downloadsStarted,
+ content.downloads.size
+ )
+ )
+ }
+ PreferenceKeys.DownloadManager.OKHTTP -> {
+
+ val folder = viewModel.getDownloadFolder()
+ if (folder != null) {
+ if (viewModel.getDownloadOnUnmeteredOnlyPreference()) {
+ val connectivityManager =
+ applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ if (connectivityManager.isActiveNetworkMetered) {
+ applicationContext.showToast(R.string.download_on_metered_connection)
+ }
+ }
+ viewModel.startMultipleDownloadWorkers(
+ folder,
+ content.downloads
+ )
+ } else
+ viewModel.requireDownloadFolder()
+ }
+ }
+ }
+ }
+ is MainActivityMessage.DownloadEnqueued -> {
+
+ if (
+ Build.VERSION.SDK_INT in 23..28 &&
+ ContextCompat.checkSelfPermission(
+ applicationContext,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ ) != PERMISSION_GRANTED
+ ) {
+ viewModel.requireDownloadPermissions()
+ } else {
+ when (val dm = viewModel.getDownloadManagerPreference()) {
+ PreferenceKeys.DownloadManager.SYSTEM -> {
+
+ val manager =
+ applicationContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+
+ val queuedDownload = manager.downloadFileInStandardFolder(
+ source = Uri.parse(content.source),
+ title = content.fileName,
+ description = getString(R.string.app_name),
+ fileName = content.fileName
+ )
+ when (queuedDownload) {
+ is EitherResult.Failure -> {
+ applicationContext.showToast(
+ getString(
+ R.string.download_not_started_format,
+ content.fileName
+ )
+ )
+ }
+ is EitherResult.Success -> {
+ applicationContext.showToast(R.string.download_started)
+ }
+ }
+ }
+ PreferenceKeys.DownloadManager.OKHTTP -> {
+
+ val folder = viewModel.getDownloadFolder()
+ if (folder != null) {
+
+ if (viewModel.getDownloadOnUnmeteredOnlyPreference()) {
+ val connectivityManager =
+ applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ if (connectivityManager.isActiveNetworkMetered) {
+ applicationContext.showToast(R.string.download_on_metered_connection)
+ }
+ }
+
+ viewModel.startDownloadWorker(
+ content,
+ folder
+ )
+ } else
+ viewModel.requireDownloadFolder()
+
+ }
+ else -> {
+ Timber.e("Unrecognized download manager requested: $dm")
+ }
+ }
+ }
}
null -> {}
}
@@ -318,6 +613,8 @@ class MainActivity : AppCompatActivity() {
}
viewModel.clearCache(applicationContext.cacheDir)
+
+ viewModel.checkUpdates(BuildConfig.VERSION_CODE, getApplicationSignatures())
}
override fun onResume() {
@@ -334,8 +631,8 @@ class MainActivity : AppCompatActivity() {
val pluginName = link.replace("%2F", "/").split("/").last()
val manager =
applicationContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- val queuedDownload = manager.downloadFile(
- link = link,
+ val queuedDownload = manager.downloadFileInStandardFolder(
+ source = Uri.parse(link),
title = getString(R.string.unchained_plugin_download),
description = getString(R.string.temporary_plugin_download),
fileName = pluginName
@@ -355,24 +652,8 @@ class MainActivity : AppCompatActivity() {
}
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- val inflater = menuInflater
- inflater.inflate(R.menu.top_app_bar, menu)
- return true
- }
-
override fun onSupportNavigateUp(): Boolean {
- return currentNavController?.value?.navigateUp(appBarConfiguration) ?: false
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.settings -> {
- openSettings()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
+ return navController.navigateUp(appBarConfiguration)
}
private fun getIntentData() {
@@ -522,50 +803,61 @@ class MainActivity : AppCompatActivity() {
/**
* Called on first creation and when restoring state.
*/
- private fun setupBottomNavigationBar() {
- val bottomNavigationView = findViewById(R.id.bottom_nav_view)
+ private fun setupBottomNavigationBar(binding: ActivityMainBinding) {
- val navGraphIds = listOf(
- R.navigation.home_nav_graph,
- R.navigation.lists_nav_graph,
- R.navigation.search_nav_graph,
- )
+ navController = (
+ supportFragmentManager.findFragmentById(
+ R.id.nav_host_fragment
+ ) as NavHostFragment
+ ).navController
+ binding.bottomNavView.setupWithNavController(navController)
- // Setup the bottom navigation view with a list of navigation graphs
- val controller = bottomNavigationView.setupWithNavController(
- navGraphIds = navGraphIds,
- fragmentManager = supportFragmentManager,
- containerId = R.id.nav_host_fragment,
- intent = intent
+ // Setup the ActionBar with navController and 3 top level destinations
+ // these won't show a back/up arrow
+ appBarConfiguration = AppBarConfiguration(
+ setOf(
+ R.id.authentication_dest,
+ R.id.start_dest,
+ R.id.user_dest,
+ R.id.list_tabs_dest,
+ R.id.search_dest
+ )
)
+ setupActionBarWithNavController(navController, appBarConfiguration)
- // Whenever the selected controller changes, setup the action bar.
- controller.observe(
- this
- ) { navController ->
- setupActionBarWithNavController(navController, appBarConfiguration)
- }
- currentNavController = controller
- }
+ binding.bottomNavView.setOnItemReselectedListener {
+ if (it.isEnabled) {
+ val currentDestination = navController.currentDestination
- override fun onRestoreInstanceState(savedInstanceState: Bundle) {
- super.onRestoreInstanceState(savedInstanceState)
- // Now that BottomNavigationBar has restored its instance state
- // and its selectedItemId, we can proceed with setting up the
- // BottomNavigationBar with Navigation
- setupBottomNavigationBar()
+ when (it.itemId) {
+ R.id.navigation_home -> {
+ // do nothing. There is no other acceptable fragment
+ }
+ // if these are enabled I should be logged in already
+ R.id.navigation_lists -> {
+ if (currentDestination?.id != R.id.list_tabs_dest) {
+ navController.popBackStack(R.id.list_tabs_dest, false)
+ }
+ }
+ R.id.navigation_search -> {
+ if (currentDestination?.id != R.id.search_dest) {
+ navController.popBackStack(R.id.search_dest, false)
+ }
+ }
+ }
+ }
+ }
}
override fun onBackPressed() {
// if the user is pressing back on an "exiting"fragment, show a toast alerting him and wait for him to press back again for confirmation
- val navController = currentNavController?.value
- if (navController != null) {
- val currentDestination = navController.currentDestination
- val previousDestination = navController.previousBackStackEntry
+ val currentDestination = navController.currentDestination
+ val previousDestination = navController.previousBackStackEntry
+ when (currentDestination?.id) {
// check if we're pressing back from the user or authentication fragment
- if (currentDestination?.id == R.id.user_dest || currentDestination?.id == R.id.authentication_dest) {
+ R.id.user_dest, R.id.authentication_dest -> {
// check the destination for the back action
if (previousDestination == null ||
previousDestination.destination.id == R.id.authentication_dest ||
@@ -587,11 +879,27 @@ class MainActivity : AppCompatActivity() {
} else {
super.onBackPressed()
}
- } else {
+ }
+ else -> {
super.onBackPressed()
}
- } else
- super.onBackPressed()
+ }
+ }
+
+ private fun showUpdateDialog(description: String, link: String) {
+
+ // passing the baseContext or applicationContext cause a crash in the release version build
+ // java.lang.IllegalArgumentException:
+ // The style on this component requires your app theme to be Theme.AppCompat (or a descendant).
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.new_update))
+ .setMessage(description)
+ .setNegativeButton(getString(R.string.close)) { _, _ ->
+ }
+ .setPositiveButton(getString(R.string.open)) { _, _ ->
+ this.openExternalWebPage(link)
+ }
+ .show()
}
companion object {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt
index 627abceb1..6a9077c18 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt
@@ -9,6 +9,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.settings.view.SettingsFragment
+import com.github.livingwithhippos.unchained.utilities.extension.getThemeColor
class ThemingCallback(val preferences: SharedPreferences) : Application.ActivityLifecycleCallbacks {
@@ -26,9 +27,12 @@ class ThemingCallback(val preferences: SharedPreferences) : Application.Activity
"tropical_sunset" -> R.style.Theme_TropicalSunset
"black_n_white" -> R.style.Theme_BlackAndWhite
"waves_01" -> R.style.Theme_Wave01
- else -> R.style.Theme_Unchained
+ else -> R.style.Theme_Wave01
}
activity.setTheme(themeID)
+ // todo: check if this can be avoided, android:navigationBarColor in xml is not working
+ activity.window.statusBarColor = activity.getThemeColor(R.attr.customStatusBarColor)
+ activity.window.navigationBarColor = activity.getThemeColor(R.attr.customNavigationBarColor)
}
override fun onActivityStarted(activity: Activity) {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt
index b95f032a8..e71af7036 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt
@@ -44,29 +44,42 @@ class UnchainedApplication : Application() {
protoStore.deleteIncompleteCredentials()
}
- createNotificationChannel()
+ createNotificationChannels()
TelemetryManager.onCreate(this)
}
- private fun createNotificationChannel() {
+ private fun createNotificationChannels() {
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = getString(R.string.app_name)
- val descriptionText = getString(R.string.torrent_channel_description)
- val importance = NotificationManager.IMPORTANCE_LOW
- val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
- description = descriptionText
+ val torrentChannel = NotificationChannel(
+ TORRENT_CHANNEL_ID,
+ name,
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.torrent_channel_description)
}
- // Register the channel with the system
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.createNotificationChannel(channel)
+
+ // Register the channels with the system
+ notificationManager.createNotificationChannel(torrentChannel)
+
+ val downloadChannel = NotificationChannel(
+ DOWNLOAD_CHANNEL_ID,
+ name,
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.download_channel_description)
+ }
+ notificationManager.createNotificationChannel(downloadChannel)
}
}
companion object {
- const val CHANNEL_ID = "unchained_torrent_channel"
+ const val TORRENT_CHANNEL_ID = "unchained_torrent_channel"
+ const val DOWNLOAD_CHANNEL_ID = "unchained_download_channel"
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CredentialsSerializer.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CredentialsSerializer.kt
index c36dfa594..36136321c 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CredentialsSerializer.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CredentialsSerializer.kt
@@ -13,7 +13,6 @@ import java.io.OutputStream
object CredentialsSerializer : Serializer {
override val defaultValue: CurrentCredential = CurrentCredential.getDefaultInstance()
- @Suppress("BlockingMethodInNonBlockingContext")
override suspend fun readFrom(input: InputStream): CurrentCredential {
try {
return CurrentCredential.parseFrom(input)
@@ -22,7 +21,6 @@ object CredentialsSerializer : Serializer {
}
}
- @Suppress("BlockingMethodInNonBlockingContext")
override suspend fun writeTo(t: CurrentCredential, output: OutputStream) = t.writeTo(output)
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt
index 26849b2bf..9f5bc598e 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt
@@ -10,11 +10,11 @@ import com.github.livingwithhippos.unchained.data.model.KodiDevice
* Annotates class to be a Room Database with a table (entity) of the Credentials class
*/
@Database(
- entities = [HostRegex::class,KodiDevice::class],
+ entities = [HostRegex::class, KodiDevice::class],
version = 5,
exportSchema = true,
autoMigrations = [
- AutoMigration (from = 4, to = 5)
+ AutoMigration(from = 4, to = 5)
]
)
abstract class UnchaineDB : RoomDatabase() {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt
index 2017fc141..47aeb29a4 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt
@@ -1,9 +1,9 @@
package com.github.livingwithhippos.unchained.data.model
-import android.os.Parcel
import android.os.Parcelable
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
+import kotlinx.parcelize.Parcelize
/*
[
@@ -34,86 +34,61 @@ import com.squareup.moshi.JsonClass
*/
@JsonClass(generateAdapter = true)
+@Parcelize
data class DownloadItem(
@Json(name = "id")
val id: String,
@Json(name = "filename")
val filename: String,
+ /**
+ * Mime Type of the file, guessed by the file extension
+ */
@Json(name = "mimeType")
val mimeType: String?,
+ /**
+ * bytes, 0 if unknown
+ */
@Json(name = "filesize")
val fileSize: Long,
+ /**
+ * Original link, use "generated" to download this file
+ */
@Json(name = "link")
val link: String,
+ /**
+ * Host main domain
+ */
@Json(name = "host")
val host: String,
@Json(name = "host_icon")
val hostIcon: String?,
+ /**
+ * Max Chunks allowed
+ */
@Json(name = "chunks")
val chunks: Int,
@Json(name = "crc")
val crc: Int?,
+ /**
+ * Generated link to be used for downloading
+ */
@Json(name = "download")
val download: String,
@Json(name = "streamable")
val streamable: Int?,
+ /**
+ * jsonDate
+ */
@Json(name = "generated")
val generated: String?,
@Json(name = "type")
val type: String?,
@Json(name = "alternative")
val alternative: List?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readLong(),
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readString(),
- parcel.readInt(),
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readString()!!,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readString(),
- parcel.readString(),
- mutableListOf().also { parcel.readTypedList(it, Alternative.CREATOR) }
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(id)
- parcel.writeString(filename)
- parcel.writeString(mimeType)
- parcel.writeLong(fileSize)
- parcel.writeString(link)
- parcel.writeString(host)
- parcel.writeString(hostIcon)
- parcel.writeInt(chunks)
- parcel.writeValue(crc)
- parcel.writeString(download)
- parcel.writeValue(streamable)
- parcel.writeString(generated)
- parcel.writeString(type)
- parcel.writeTypedList(alternative)
- }
-
- override fun describeContents(): Int {
- return id.hashCode()
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): DownloadItem {
- return DownloadItem(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class Alternative(
@Json(name = "id")
val id: String,
@@ -125,34 +100,4 @@ data class Alternative(
val mimeType: String?,
@Json(name = "quality")
val quality: String?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readString(),
- parcel.readString()
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(id)
- parcel.writeString(filename)
- parcel.writeString(download)
- parcel.writeString(mimeType)
- parcel.writeString(quality)
- }
-
- override fun describeContents(): Int {
- return id.hashCode()
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): Alternative {
- return Alternative(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiDevice.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiDevice.kt
index cc352529d..51907e856 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiDevice.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiDevice.kt
@@ -5,7 +5,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "kodi_device")
-class KodiDevice (
+class KodiDevice(
@PrimaryKey
@ColumnInfo(name = "name")
val name: String,
@@ -19,4 +19,4 @@ class KodiDevice (
val password: String?,
@ColumnInfo(name = "is_default")
val isDefault: Boolean = false,
-)
\ No newline at end of file
+)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt
index e94f17c0a..eb5ab9d59 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt
@@ -1,9 +1,9 @@
package com.github.livingwithhippos.unchained.data.model
-import android.os.Parcel
import android.os.Parcelable
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
+import kotlinx.parcelize.Parcelize
/*
[
@@ -43,7 +43,32 @@ import com.squareup.moshi.JsonClass
]
*/
+/**
+ * Torrent item: this class is used for both the /torrents and /torrents/info/{id} endpoint
+ * even if the returned item are different. For example the info version returns the original file size
+ * and the selected files one, while the /torrents one returns only the original file size
+ * (even if the docs say otherwise)
+ *
+ * @property id
+ * @property filename
+ * @property originalFilename
+ * @property hash
+ * @property bytes
+ * @property originalBytes
+ * @property host
+ * @property split
+ * @property progress
+ * @property status
+ * @property added
+ * @property files
+ * @property links
+ * @property ended
+ * @property speed
+ * @property seeders
+ * @constructor Create empty Torrent item
+ */
@JsonClass(generateAdapter = true)
+@Parcelize
data class TorrentItem(
@Json(name = "id")
val id: String,
@@ -77,61 +102,10 @@ data class TorrentItem(
val speed: Int?,
@Json(name = "seeders")
val seeders: Int?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readString(),
- parcel.readString()!!,
- parcel.readLong(),
- parcel.readValue(Long::class.java.classLoader) as? Long,
- parcel.readString()!!,
- parcel.readInt(),
- parcel.readInt(),
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.createTypedArrayList(InnerTorrentFile),
- parcel.createStringArrayList() ?: emptyList(),
- parcel.readString(),
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(id)
- parcel.writeString(filename)
- parcel.writeString(originalFilename)
- parcel.writeString(hash)
- parcel.writeLong(bytes)
- parcel.writeValue(originalBytes)
- parcel.writeString(host)
- parcel.writeInt(split)
- parcel.writeInt(progress)
- parcel.writeString(status)
- parcel.writeString(added)
- parcel.writeTypedList(files)
- parcel.writeStringList(links)
- parcel.writeString(ended)
- parcel.writeValue(speed)
- parcel.writeValue(seeders)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): TorrentItem {
- return TorrentItem(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class InnerTorrentFile(
@Json(name = "id")
val id: Int,
@@ -141,96 +115,22 @@ data class InnerTorrentFile(
val bytes: Long,
@Json(name = "selected")
val selected: Int
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readInt(),
- parcel.readString() ?: "",
- parcel.readLong(),
- parcel.readInt()
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeInt(id)
- parcel.writeString(path)
- parcel.writeLong(bytes)
- parcel.writeInt(selected)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): InnerTorrentFile {
- return InnerTorrentFile(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class UploadedTorrent(
@Json(name = "id")
val id: String,
@Json(name = "uri")
val uri: String
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString() ?: "",
- parcel.readString() ?: ""
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(id)
- parcel.writeString(uri)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): UploadedTorrent {
- return UploadedTorrent(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class AvailableHost(
@Json(name = "host")
val host: String,
@Json(name = "max_file_size")
val maxFileSize: Int
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString() ?: "",
- parcel.readInt()
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(host)
- parcel.writeInt(maxFileSize)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): AvailableHost {
- return AvailableHost(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt
new file mode 100644
index 000000000..5373fa497
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt
@@ -0,0 +1,22 @@
+package com.github.livingwithhippos.unchained.data.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class Updates(
+ @Json(name = "play_store")
+ val playStore: VersionData?,
+ @Json(name = "f_droid")
+ val fDroid: VersionData?,
+ @Json(name = "github")
+ val github: VersionData?
+)
+
+@JsonClass(generateAdapter = true)
+data class VersionData(
+ @Json(name = "signature")
+ val signature: String,
+ @Json(name = "versionCode")
+ val versionCode: Int
+)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt
new file mode 100644
index 000000000..3a57a2275
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt
@@ -0,0 +1,117 @@
+package com.github.livingwithhippos.unchained.data.model.cache
+
+import android.os.Parcelable
+import com.squareup.moshi.FromJson
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.JsonReader
+import com.squareup.moshi.JsonWriter
+import com.squareup.moshi.ToJson
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+@Parcelize
+data class InstantAvailability(
+ val cachedTorrents: List
+) : Parcelable
+
+@Parcelize
+data class CachedTorrent(
+ val btih: String,
+ // i may have a list of providers, for now I always see only "rd"
+ val cachedAlternatives: List
+) : Parcelable
+
+@Parcelize
+data class CachedAlternative(
+ val cachedFiles: List
+) : Parcelable
+
+@Parcelize
+data class CachedFile(
+ val id: Long,
+ val fileName: String,
+ val fileSize: Long
+) : Parcelable
+
+class CachedRequestAdapter : JsonAdapter() {
+
+ @FromJson
+ override fun fromJson(reader: JsonReader): InstantAvailability {
+ val cachedTorrents = mutableListOf()
+ reader.beginObject()
+ // always available for cached and missing
+ var key: String?
+ while (reader.hasNext()) {
+ // Start CachedTorrent
+ key = reader.nextName()
+
+ when (reader.peek()) {
+ JsonReader.Token.BEGIN_OBJECT -> {
+ // cached content
+ reader.beginObject()
+ val cachedAlternatives = mutableListOf()
+ // cache providers, still has to see something else beside "rd"
+ val provider = reader.nextName()
+ if (provider == "rd") {
+ // start list of CachedAlternative
+ reader.beginArray()
+ while (reader.hasNext()) {
+ reader.beginObject()
+ // start CachedFile list
+ val cachedFiles = mutableListOf()
+ while (reader.hasNext()) {
+ val currId = reader.nextName().toLong()
+ reader.beginObject()
+ var fileName = ""
+ var fileSize = 0L
+ while (reader.hasNext()) {
+ when (val token = reader.nextName()) {
+ "filename" -> fileName = reader.nextString()
+ "filesize" -> fileSize = reader.nextLong()
+ else -> {
+ Timber.d("skip inner token $token")
+ reader.skipValue()
+ }
+ }
+ }
+ reader.endObject()
+ cachedFiles.add(CachedFile(currId, fileName, fileSize))
+ }
+ reader.endObject()
+ cachedAlternatives.add(
+ CachedAlternative(cachedFiles)
+ )
+ }
+ // finished list of CachedAlternatives
+ reader.endArray()
+ } else {
+ // check ??
+ Timber.d("Provider: $provider")
+ }
+ // finished CachedTorrent
+ reader.endObject()
+ cachedTorrents.add(CachedTorrent(key, cachedAlternatives))
+ }
+ JsonReader.Token.BEGIN_ARRAY -> {
+ // cache-less content
+ // evaluate if torrent without cache could be added to the output with an empty map
+ Timber.d("Skipping empty torrent: $key")
+ reader.skipValue()
+ }
+ else -> {
+ // ??
+ Timber.d("Skipped cachedTorrent")
+ reader.skipValue()
+ }
+ }
+ }
+
+ reader.endObject()
+ return InstantAvailability(cachedTorrents)
+ }
+
+ @ToJson
+ override fun toJson(writer: JsonWriter, value: InstantAvailability?) {
+ TODO("Not yet implemented")
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownload.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownload.kt
index 35ab16a1f..ac9082952 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownload.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownload.kt
@@ -7,7 +7,6 @@ import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url
-
interface CustomDownload {
@Streaming
@@ -21,4 +20,4 @@ interface CustomDownload {
suspend fun getFile(
@Url url: String
): Response
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownloadHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownloadHelperImpl.kt
index 6f6832507..02792b028 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownloadHelperImpl.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/CustomDownloadHelperImpl.kt
@@ -8,5 +8,6 @@ class CustomDownloadHelperImpl @Inject constructor(private val customDownload: C
CustomDownloadHelper {
override suspend fun getFile(url: String): Response = customDownload.getFile(url)
- override suspend fun getPluginsPack(packUrl: String): Response = customDownload.getPluginsPack(packUrl)
-}
\ No newline at end of file
+ override suspend fun getPluginsPack(packUrl: String): Response =
+ customDownload.getPluginsPack(packUrl)
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt
index 2a223b430..f2a2afeee 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt
@@ -42,7 +42,8 @@ class KodiSocket @Inject constructor(private val client: OkHttpClient) {
super.onFailure(webSocket, t, response)
trySend(
WebSocketEvents.ConnectionError(
- t.message ?: response?.message ?: "Failure using the websocket for url $url"
+ t.message ?: response?.message
+ ?: "Failure using the websocket for url $url"
)
)
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt
index e679ecb31..82ecf337b 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt
@@ -3,6 +3,7 @@ package com.github.livingwithhippos.unchained.data.remote
import com.github.livingwithhippos.unchained.data.model.AvailableHost
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UploadedTorrent
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import okhttp3.RequestBody
import retrofit2.Response
@@ -45,4 +46,9 @@ interface TorrentApiHelper {
token: String,
id: String
): Response
+
+ suspend fun getInstantAvailability(
+ token: String,
+ url: String
+ ): Response
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt
index bb4a04855..b6cf725c8 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt
@@ -3,6 +3,7 @@ package com.github.livingwithhippos.unchained.data.remote
import com.github.livingwithhippos.unchained.data.model.AvailableHost
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UploadedTorrent
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import okhttp3.RequestBody
import retrofit2.Response
import javax.inject.Inject
@@ -40,4 +41,9 @@ class TorrentApiHelperImpl @Inject constructor(private val torrentsApi: Torrents
override suspend fun deleteTorrent(token: String, id: String) =
torrentsApi.deleteTorrent(token, id)
+
+ override suspend fun getInstantAvailability(
+ token: String,
+ url: String
+ ): Response = torrentsApi.getInstantAvailability(token, url)
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt
index f12294e41..f296dbeb8 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt
@@ -3,6 +3,7 @@ package com.github.livingwithhippos.unchained.data.remote
import com.github.livingwithhippos.unchained.data.model.AvailableHost
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UploadedTorrent
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
@@ -15,6 +16,7 @@ import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
+import retrofit2.http.Url
/**
* This interface is used by Retrofit to manage all the REST calls to the torrents endpoints
@@ -89,4 +91,16 @@ interface TorrentsApi {
@Header("Authorization") token: String,
@Path("id") id: String,
): Response
+
+ /**
+ * Check if any file of a torrent/magnet is already cached using their hashes
+ * Hashes must be passed like this:
+ * https://api.real-debrid.com/rest/1.0/torrents/instantAvailability/HASH_1/HASH_2/HASH_3
+ * etc. for any
+ */
+ @GET
+ suspend fun getInstantAvailability(
+ @Header("Authorization") token: String,
+ @Url url: String
+ ): Response
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UpdateApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UpdateApi.kt
new file mode 100644
index 000000000..ecb30b891
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UpdateApi.kt
@@ -0,0 +1,18 @@
+package com.github.livingwithhippos.unchained.data.remote
+
+import com.github.livingwithhippos.unchained.data.model.Updates
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Url
+
+interface UpdateApi {
+ @GET
+ suspend fun getUpdates(
+ @Url url: String
+ ): Response
+}
+
+interface UpdateApiHelper {
+
+ suspend fun getUpdates(url: String): Response
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UpdateApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UpdateApiHelperImpl.kt
new file mode 100644
index 000000000..af7e0f93b
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UpdateApiHelperImpl.kt
@@ -0,0 +1,10 @@
+package com.github.livingwithhippos.unchained.data.remote
+
+import com.github.livingwithhippos.unchained.data.model.Updates
+import retrofit2.Response
+import javax.inject.Inject
+
+class UpdateApiHelperImpl @Inject constructor(private val updateApi: UpdateApi) :
+ UpdateApiHelper {
+ override suspend fun getUpdates(url: String): Response = updateApi.getUpdates(url)
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UserApiHelper.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UserApiHelper.kt
index aaef59b73..8c96cd967 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UserApiHelper.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UserApiHelper.kt
@@ -4,6 +4,5 @@ import com.github.livingwithhippos.unchained.data.model.User
import retrofit2.Response
interface UserApiHelper {
-
suspend fun getUser(token: String): Response
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt
index 8039e210d..2e09b6d13 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt
@@ -1,5 +1,6 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.Authentication
import com.github.livingwithhippos.unchained.data.model.Secrets
import com.github.livingwithhippos.unchained.data.model.Token
@@ -8,8 +9,11 @@ import com.github.livingwithhippos.unchained.data.remote.AuthApiHelper
import com.github.livingwithhippos.unchained.utilities.EitherResult
import javax.inject.Inject
-class AuthenticationRepository @Inject constructor(private val apiHelper: AuthApiHelper) :
- BaseRepository() {
+class AuthenticationRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val apiHelper: AuthApiHelper
+) :
+ BaseRepository(protoStore) {
suspend fun getVerificationCode(): Authentication? {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt
index 1ca04ef8b..ab52d8f58 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt
@@ -1,5 +1,6 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.APIError
import com.github.livingwithhippos.unchained.data.model.ApiConversionError
import com.github.livingwithhippos.unchained.data.model.EmptyBodyError
@@ -19,7 +20,7 @@ import java.io.IOException
* Base repository class to be extended by other repositories.
* Manages the calls between retrofit and the actual repositories.
*/
-open class BaseRepository {
+open class BaseRepository(private val protoStore: ProtoStore) {
// todo: inject this
private val jsonAdapter: JsonAdapter = Moshi.Builder()
@@ -76,13 +77,25 @@ open class BaseRepository {
} catch (e: Exception) {
return@withContext EitherResult.Failure(NetworkError(-1, errorMessage))
}
-
+ val code = response.code()
if (response.isSuccessful) {
- val body = response.body()
+ val body: T? = response.body()
return@withContext if (body != null)
EitherResult.Success(body)
else
- EitherResult.Failure(EmptyBodyError(response.code()))
+ // todo: some calls return nothing and this is actually a Success, manage them
+ /**
+ * /disable_access_token
+ * /downloads/delete/{id}
+ * /torrents/selectFiles/{id}
+ * /torrents/delete/{id}
+ * /settings/update
+ * /settings/convertPoints
+ * /settings/changePassword
+ * /settings/avatarFile
+ * /settings/avatarDelete
+ */
+ EitherResult.Failure(EmptyBodyError(code))
} else {
try {
val error: APIError? = jsonAdapter.fromJson(response.errorBody()!!.string())
@@ -92,8 +105,26 @@ open class BaseRepository {
EitherResult.Failure(ApiConversionError(-1))
} catch (e: IOException) {
// todo: analyze error to return code
- return@withContext EitherResult.Failure(NetworkError(-1, errorMessage))
+ return@withContext EitherResult.Failure(
+ NetworkError(
+ -1,
+ "$errorMessage, http code $code"
+ )
+ )
}
}
}
+
+ /**
+ * Get the access token saved in the db. Used by most calls to RD APIs
+ * Throws an exception if token is missing or malformed
+ * @return the token string
+ */
+ suspend fun getToken(): String {
+ val token = protoStore.getCredentials().accessToken
+ if (token.isBlank() || token.length < 5)
+ throw IllegalArgumentException("Loaded token was empty or wrong: $token")
+
+ return token
+ }
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt
index 0af81d18e..4bc040353 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt
@@ -1,5 +1,6 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.remote.CustomDownloadHelper
import com.github.livingwithhippos.unchained.utilities.extension.isWebUrl
import kotlinx.coroutines.Dispatchers
@@ -13,16 +14,20 @@ import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
-class CustomDownloadRepository @Inject constructor(private val customDownloadHelper: CustomDownloadHelper) :
- BaseRepository() {
+class CustomDownloadRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val customDownloadHelper: CustomDownloadHelper
+) :
+ BaseRepository(protoStore) {
- suspend fun downloadToCache(
+ fun downloadToCache(
url: String,
fileName: String,
cacheDir: File,
suffix: String? = null
): Flow = channelFlow {
if (url.isWebUrl()) {
+ // todo: use the FileWriter and Downloader helper classes
val call = customDownloadHelper.getFile(url)
if (call.isSuccessful) {
val body = call.body()
@@ -59,7 +64,6 @@ class CustomDownloadRepository @Inject constructor(private val customDownloadHel
// todo: add check for fileSizeDownloaded and fileSize difference
outputStream.flush()
successfulDownload = true
-
} catch (e: IOException) {
send(DownloadResult.Failure)
sentEnding = true
@@ -77,7 +81,6 @@ class CustomDownloadRepository @Inject constructor(private val customDownloadHel
} else send(DownloadResult.Failure)
} else send(DownloadResult.WrongURL)
}
-
}
sealed class DownloadResult {
@@ -85,4 +88,4 @@ sealed class DownloadResult {
object WrongURL : DownloadResult()
data class End(val fileName: String) : DownloadResult()
object Failure : DownloadResult()
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt
index 95fbd695a..ac8a90f77 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt
@@ -1,32 +1,35 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.remote.DownloadApiHelper
import javax.inject.Inject
-class DownloadRepository @Inject constructor(private val downloadApiHelper: DownloadApiHelper) :
- BaseRepository() {
+class DownloadRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val downloadApiHelper: DownloadApiHelper
+) :
+ BaseRepository(protoStore) {
suspend fun getDownloads(
- token: String,
offset: Int?,
page: Int = 1,
limit: Int = 50
): List {
val downloadResponse = safeApiCall(
- call = { downloadApiHelper.getDownloads("Bearer $token", offset, page, limit) },
+ call = { downloadApiHelper.getDownloads("Bearer ${getToken()}", offset, page, limit) },
errorMessage = "Error Fetching Downloads list or list empty"
)
return downloadResponse ?: emptyList()
}
- suspend fun deleteDownload(token: String, id: String): Unit? {
+ suspend fun deleteDownload(id: String): Unit? {
val response = safeApiCall(
call = {
downloadApiHelper.deleteDownload(
- token = "Bearer $token",
+ token = "Bearer ${getToken()}",
id = id
)
},
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt
index 4134edb74..273f55b23 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt
@@ -1,6 +1,7 @@
package com.github.livingwithhippos.unchained.data.repository
import com.github.livingwithhippos.unchained.data.local.HostRegexDao
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.Host
import com.github.livingwithhippos.unchained.data.model.HostRegex
import com.github.livingwithhippos.unchained.data.model.REGEX_TYPE_FOLDER
@@ -11,15 +12,16 @@ import java.util.regex.PatternSyntaxException
import javax.inject.Inject
class HostsRepository @Inject constructor(
+ private val protoStore: ProtoStore,
private val hostsApiHelper: HostsApiHelper,
private val hostRegexDao: HostRegexDao
) :
- BaseRepository() {
+ BaseRepository(protoStore) {
- suspend fun getHostsStatus(token: String): Host? {
+ suspend fun getHostsStatus(): Host? {
val hostResponse = safeApiCall(
- call = { hostsApiHelper.getHostsStatus("Bearer $token") },
+ call = { hostsApiHelper.getHostsStatus("Bearer ${getToken()}") },
errorMessage = "Error Fetching Streaming Info"
)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt
index 6be81b237..5366f0aef 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt
@@ -7,12 +7,11 @@ import javax.inject.Inject
class KodiDeviceRepository @Inject constructor(
private val kodiDeviceDao: KodiDeviceDao
-) : BaseRepository() {
+) {
val devicesFlow: Flow>
get() = kodiDeviceDao.getAllDevicesFlow()
-
suspend fun getDevices(): List {
return kodiDeviceDao.getAllDevices()
}
@@ -59,4 +58,4 @@ class KodiDeviceRepository @Inject constructor(
oldDeviceName
)
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt
index 3fbee8c8e..bd8ef05be 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt
@@ -1,6 +1,7 @@
package com.github.livingwithhippos.unchained.data.repository
import android.util.Base64
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.KodiGenericResponse
import com.github.livingwithhippos.unchained.data.model.KodiItem
import com.github.livingwithhippos.unchained.data.model.KodiParams
@@ -17,8 +18,9 @@ import timber.log.Timber
import javax.inject.Inject
class KodiRepository @Inject constructor(
+ private val protoStore: ProtoStore,
@ClassicClient private val client: OkHttpClient
-) : BaseRepository() {
+) : BaseRepository(protoStore) {
private fun provideRetrofit(baseUrl: String): Retrofit {
return Retrofit.Builder()
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt
index 4f2263008..3714babaa 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt
@@ -217,7 +217,6 @@ class PluginRepository @Inject constructor(
null
}
-
suspend fun readPluginFile(pluginFile: File): Plugin? =
withContext(Dispatchers.IO) {
try {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt
index 02831dfc6..b86a4e151 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt
@@ -1,16 +1,20 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.Stream
import com.github.livingwithhippos.unchained.data.remote.StreamingApiHelper
import javax.inject.Inject
-class StreamingRepository @Inject constructor(private val streamingApiHelper: StreamingApiHelper) :
- BaseRepository() {
+class StreamingRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val streamingApiHelper: StreamingApiHelper
+) :
+ BaseRepository(protoStore) {
- suspend fun getStreams(token: String, id: String): Stream? {
+ suspend fun getStreams(id: String): Stream? {
val streamResponse = safeApiCall(
- call = { streamingApiHelper.getStreams("Bearer $token", id) },
+ call = { streamingApiHelper.getStreams("Bearer ${getToken()}", id) },
errorMessage = "Error Fetching Streaming Info"
)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt
index d0874939a..4b7327085 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt
@@ -1,9 +1,11 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.AvailableHost
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
import com.github.livingwithhippos.unchained.data.model.UploadedTorrent
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import com.github.livingwithhippos.unchained.data.remote.TorrentApiHelper
import com.github.livingwithhippos.unchained.utilities.EitherResult
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -12,10 +14,14 @@ import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import javax.inject.Inject
-class TorrentsRepository @Inject constructor(private val torrentApiHelper: TorrentApiHelper) :
- BaseRepository() {
+class TorrentsRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val torrentApiHelper: TorrentApiHelper
+) :
+ BaseRepository(protoStore) {
- suspend fun getAvailableHosts(token: String): List? {
+ suspend fun getAvailableHosts(): List? {
+ val token = getToken()
val hostResponse: List? = safeApiCall(
call = { torrentApiHelper.getAvailableHosts(token = "Bearer $token") },
errorMessage = "Error Retrieving Available Hosts"
@@ -25,9 +31,9 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
}
suspend fun getTorrentInfo(
- token: String,
id: String
): TorrentItem? {
+ val token = getToken()
val torrentResponse: TorrentItem? = safeApiCall(
call = {
torrentApiHelper.getTorrentInfo(
@@ -42,10 +48,10 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
}
suspend fun addTorrent(
- token: String,
binaryTorrent: ByteArray,
host: String
): EitherResult {
+ val token = getToken()
val requestBody: RequestBody = binaryTorrent.toRequestBody(
"application/octet-stream".toMediaTypeOrNull(),
@@ -68,10 +74,10 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
}
suspend fun addMagnet(
- token: String,
magnet: String,
host: String
): EitherResult {
+ val token = getToken()
val torrentResponse = eitherApiResult(
call = {
torrentApiHelper.addMagnet(
@@ -87,12 +93,12 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
}
suspend fun getTorrentsList(
- token: String,
offset: Int? = null,
page: Int? = 1,
limit: Int? = 50,
filter: String? = null
): List {
+ val token = getToken()
val torrentsResponse: List? = safeApiCall(
call = {
@@ -111,14 +117,14 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
}
suspend fun selectFiles(
- token: String,
id: String,
files: String = "all"
- ) {
+ ): EitherResult {
+ val token = getToken()
Timber.d("Selecting files for torrent: $id")
// this call has no return type
- safeApiCall(
+ val response = eitherApiResult(
call = {
torrentApiHelper.selectFiles(
token = "Bearer $token",
@@ -128,12 +134,14 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
},
errorMessage = "Error Selecting Torrent Files"
)
+
+ return response
}
suspend fun deleteTorrent(
- token: String,
id: String
): EitherResult {
+ val token = getToken()
val response = eitherApiResult(
call = {
@@ -147,4 +155,22 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
return response
}
+
+ suspend fun getInstantAvailability(
+ url: String
+ ): EitherResult {
+ val token = getToken()
+
+ val response = eitherApiResult(
+ call = {
+ torrentApiHelper.getInstantAvailability(
+ token = "Bearer $token",
+ url = url
+ )
+ },
+ errorMessage = "Error getting cached torrent files"
+ )
+
+ return response
+ }
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt
index 723ba1c3f..b7e0a1dfe 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt
@@ -1,5 +1,6 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
import com.github.livingwithhippos.unchained.data.remote.UnrestrictApiHelper
@@ -10,15 +11,18 @@ import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
-class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper: UnrestrictApiHelper) :
- BaseRepository() {
+class UnrestrictRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val unrestrictApiHelper: UnrestrictApiHelper
+) :
+ BaseRepository(protoStore) {
suspend fun getEitherUnrestrictedLink(
- token: String,
link: String,
password: String? = null,
remote: Int? = null
): EitherResult {
+ val token = getToken()
val linkResponse = eitherApiResult(
call = {
@@ -36,7 +40,6 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
}
suspend fun getUnrestrictedLinkList(
- token: String,
linksList: List,
password: String? = null,
remote: Int? = null,
@@ -45,7 +48,7 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
val unrestrictedLinks =
mutableListOf>()
linksList.forEach {
- unrestrictedLinks.add(getEitherUnrestrictedLink(token, it, password, remote))
+ unrestrictedLinks.add(getEitherUnrestrictedLink(it, password, remote))
// just to be on the safe side...
delay(callDelay)
}
@@ -53,11 +56,11 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
}
suspend fun getEitherUnrestrictedFolder(
- token: String,
link: String,
password: String? = null,
remote: Int? = null
): List> {
+ val token = getToken()
val folderResponse: EitherResult> = eitherApiResult(
call = {
@@ -71,7 +74,6 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
return when (folderResponse) {
is EitherResult.Success -> getUnrestrictedLinkList(
- token,
folderResponse.success,
password,
remote
@@ -81,9 +83,9 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
}
suspend fun getEitherFolderLinks(
- token: String,
link: String
): EitherResult> {
+ val token = getToken()
val folderResponse: EitherResult> = eitherApiResult(
call = {
@@ -99,9 +101,9 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
}
suspend fun uploadContainer(
- token: String,
container: ByteArray
): EitherResult> {
+ val token = getToken()
val requestBody: RequestBody = container.toRequestBody(
"application/octet-stream".toMediaTypeOrNull(),
@@ -123,9 +125,9 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
}
suspend fun getContainerLinks(
- token: String,
link: String
): List? {
+ val token = getToken()
val containerResponse = safeApiCall(
call = {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt
new file mode 100644
index 000000000..a0b369175
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt
@@ -0,0 +1,26 @@
+package com.github.livingwithhippos.unchained.data.repository
+
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
+import com.github.livingwithhippos.unchained.data.model.Updates
+import com.github.livingwithhippos.unchained.data.remote.UpdateApiHelper
+import com.github.livingwithhippos.unchained.utilities.SIGNATURE
+import javax.inject.Inject
+
+class UpdateRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val updateApiHelper: UpdateApiHelper
+) :
+ BaseRepository(protoStore) {
+
+ suspend fun getUpdates(url: String = SIGNATURE.URL): Updates? {
+
+ val response = safeApiCall(
+ call = {
+ updateApiHelper.getUpdates(url)
+ },
+ errorMessage = "Error getting updates"
+ )
+
+ return response
+ }
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt
index 4688bbe7b..4c2c4280f 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt
@@ -1,13 +1,17 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
import com.github.livingwithhippos.unchained.data.model.User
import com.github.livingwithhippos.unchained.data.remote.UserApiHelper
import com.github.livingwithhippos.unchained.utilities.EitherResult
import javax.inject.Inject
-class UserRepository @Inject constructor(private val userApiHelper: UserApiHelper) :
- BaseRepository() {
+class UserRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val userApiHelper: UserApiHelper
+) :
+ BaseRepository(protoStore) {
suspend fun getUserInfo(token: String): User? {
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt
index e16b00f51..3d1088720 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt
@@ -1,17 +1,21 @@
package com.github.livingwithhippos.unchained.data.repository
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.remote.VariousApiHelper
import javax.inject.Inject
-class VariousApiRepository @Inject constructor(private val variousApiHelper: VariousApiHelper) :
- BaseRepository() {
+class VariousApiRepository @Inject constructor(
+ private val protoStore: ProtoStore,
+ private val variousApiHelper: VariousApiHelper
+) :
+ BaseRepository(protoStore) {
- suspend fun disableToken(token: String): Unit? {
+ suspend fun disableToken(): Unit? {
val response = safeApiCall(
call = {
variousApiHelper.disableToken(
- token = "Bearer $token"
+ token = "Bearer ${getToken()}"
)
},
errorMessage = "Error disabling token"
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt
index df6484a17..d57e5f4af 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt
@@ -15,11 +15,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.base.MainActivity
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
-import com.github.livingwithhippos.unchained.di.SummaryNotification
import com.github.livingwithhippos.unchained.di.TorrentNotification
+import com.github.livingwithhippos.unchained.di.TorrentSummaryNotification
import com.github.livingwithhippos.unchained.settings.view.SettingsFragment
import com.github.livingwithhippos.unchained.utilities.extension.getStatusTranslation
import com.github.livingwithhippos.unchained.utilities.extension.vibrate
@@ -35,15 +34,12 @@ class ForegroundTorrentService : LifecycleService() {
@Inject
lateinit var torrentRepository: TorrentsRepository
- @Inject
- lateinit var protoStore: ProtoStore
-
private val torrentBinder = TorrentBinder()
private val torrentsLiveData = MutableLiveData>()
@Inject
- @SummaryNotification
+ @TorrentSummaryNotification
lateinit var summaryBuilder: NotificationCompat.Builder
@Inject
@@ -160,9 +156,7 @@ class ForegroundTorrentService : LifecycleService() {
}
private suspend fun getTorrentList(max: Int = 30): List {
- // todo: manage token values
- val token = protoStore.getCredentials().accessToken
- return torrentRepository.getTorrentsList(token, limit = max)
+ return torrentRepository.getTorrentsList(limit = max)
}
private fun updateNotification(items: List) {
@@ -209,7 +203,6 @@ class ForegroundTorrentService : LifecycleService() {
else
PendingIntent.FLAG_UPDATE_CURRENT
)
-
}
torrentBuilder.setContentIntent(resultPendingIntent)
@@ -220,6 +213,7 @@ class ForegroundTorrentService : LifecycleService() {
summaryBuilder.setContentText(getString(R.string.downloading_torrent_format, items.size))
notificationManager.apply {
+ // todo: manage permission
notifications.forEach { (id, notification) ->
notify(id.hashCode(), notification)
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt
index f5f7d8655..3673af825 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt
@@ -3,6 +3,7 @@ package com.github.livingwithhippos.unchained.di
import android.content.SharedPreferences
import com.github.livingwithhippos.unchained.BuildConfig
import com.github.livingwithhippos.unchained.data.model.EmptyBodyInterceptor
+import com.github.livingwithhippos.unchained.data.model.cache.CachedRequestAdapter
import com.github.livingwithhippos.unchained.data.remote.AuthApiHelper
import com.github.livingwithhippos.unchained.data.remote.AuthApiHelperImpl
import com.github.livingwithhippos.unchained.data.remote.AuthenticationApi
@@ -24,6 +25,9 @@ import com.github.livingwithhippos.unchained.data.remote.TorrentsApi
import com.github.livingwithhippos.unchained.data.remote.UnrestrictApi
import com.github.livingwithhippos.unchained.data.remote.UnrestrictApiHelper
import com.github.livingwithhippos.unchained.data.remote.UnrestrictApiHelperImpl
+import com.github.livingwithhippos.unchained.data.remote.UpdateApi
+import com.github.livingwithhippos.unchained.data.remote.UpdateApiHelper
+import com.github.livingwithhippos.unchained.data.remote.UpdateApiHelperImpl
import com.github.livingwithhippos.unchained.data.remote.UserApi
import com.github.livingwithhippos.unchained.data.remote.UserApiHelper
import com.github.livingwithhippos.unchained.data.remote.UserApiHelperImpl
@@ -166,6 +170,7 @@ object ApiFactory {
@ApiRetrofit
fun apiRetrofit(@ClassicClient okHttpClient: OkHttpClient): Retrofit {
val moshi = Moshi.Builder()
+ .add(CachedRequestAdapter())
.add(KotlinJsonAdapterFactory())
.build()
@@ -270,6 +275,18 @@ object ApiFactory {
fun provideVariousApiHelper(apiHelper: VariousApiHelperImpl): VariousApiHelper =
apiHelper
+ // update api injection
+ @Provides
+ @Singleton
+ fun provideUpdateApi(@ApiRetrofit retrofit: Retrofit): UpdateApi {
+ return retrofit.create(UpdateApi::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideUpdateApiHelper(apiHelper: UpdateApiHelperImpl): UpdateApiHelper =
+ apiHelper
+
// custom download injection
@Provides
@Singleton
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/NotificationModule.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/NotificationModule.kt
index 490c46e52..33eebab79 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/NotificationModule.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/NotificationModule.kt
@@ -23,7 +23,7 @@ object NotificationModule {
fun provideTorrentNotificationBuilder(
@ApplicationContext applicationContext: Context
): NotificationCompat.Builder =
- NotificationCompat.Builder(applicationContext, UnchainedApplication.CHANNEL_ID)
+ NotificationCompat.Builder(applicationContext, UnchainedApplication.TORRENT_CHANNEL_ID)
.setSmallIcon(R.drawable.logo_no_background)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
@@ -31,11 +31,11 @@ object NotificationModule {
@ServiceScoped
@Provides
- @SummaryNotification
- fun provideSummaryNotificationBuilder(
+ @TorrentSummaryNotification
+ fun provideTorrentSummaryNotificationBuilder(
@ApplicationContext applicationContext: Context
): NotificationCompat.Builder =
- NotificationCompat.Builder(applicationContext, UnchainedApplication.CHANNEL_ID)
+ NotificationCompat.Builder(applicationContext, UnchainedApplication.TORRENT_CHANNEL_ID)
.setSmallIcon(R.drawable.logo_no_background)
.setContentTitle(applicationContext.getString(R.string.monitor_torrents_download))
.setPriority(NotificationCompat.PRIORITY_LOW)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt
index a7a90b0e6..ef19e38de 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt
@@ -16,7 +16,15 @@ annotation class TorrentNotification
@Qualifier
@Retention(AnnotationRetention.BINARY)
-annotation class SummaryNotification
+annotation class DownloadNotification
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class TorrentSummaryNotification
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DownloadSummaryNotification
@Qualifier
@Retention(AnnotationRetention.BINARY)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt
index 5deb1e3e1..1929daa61 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt
@@ -1,12 +1,9 @@
package com.github.livingwithhippos.unchained.downloaddetails.view
-import android.Manifest
-import android.app.DownloadManager
+import android.content.ActivityNotFoundException
import android.content.ComponentName
-import android.content.Context
import android.content.Intent
import android.net.Uri
-import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -14,9 +11,11 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import com.github.livingwithhippos.unchained.R
@@ -29,17 +28,18 @@ import com.github.livingwithhippos.unchained.downloaddetails.model.AlternativeDo
import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadDetailsMessage
import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadDetailsViewModel
import com.github.livingwithhippos.unchained.lists.view.ListState
-import com.github.livingwithhippos.unchained.lists.view.ListsTabFragment
-import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.EventObserver
import com.github.livingwithhippos.unchained.utilities.RD_STREAMING_URL
+import com.github.livingwithhippos.unchained.utilities.download.ProgressCallback
import com.github.livingwithhippos.unchained.utilities.extension.copyToClipboard
-import com.github.livingwithhippos.unchained.utilities.extension.downloadFile
import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage
import com.github.livingwithhippos.unchained.utilities.extension.showToast
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
-
+import timber.log.Timber
/**
* A simple [UnchainedFragment] subclass.
@@ -52,6 +52,9 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
private val args: DownloadDetailsFragmentArgs by navArgs()
+ private val job = Job()
+ private val ioScope = CoroutineScope(Dispatchers.IO + job)
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -59,6 +62,33 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
): View {
val detailsBinding = FragmentDownloadDetailsBinding.inflate(inflater, container, false)
+ val menuHost: MenuHost = requireActivity()
+
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.download_details_bar, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.delete -> {
+ val dialog = DeleteDialogFragment()
+ val bundle = Bundle()
+ val title =
+ getString(R.string.delete_item_title_format, args.details.filename)
+ bundle.putString("title", title)
+ dialog.arguments = bundle
+ dialog.show(parentFragmentManager, "DeleteDialogFragment")
+ true
+ }
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner, Lifecycle.State.RESUMED
+ )
+
detailsBinding.details = args.details
detailsBinding.listener = this
@@ -77,9 +107,11 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
detailsBinding.showKodi = viewModel.getButtonVisibilityPreference(SHOW_KODI_BUTTON)
detailsBinding.showLocalPlay = viewModel.getButtonVisibilityPreference(SHOW_MEDIA_BUTTON)
detailsBinding.showLoadStream = viewModel.getButtonVisibilityPreference(
- SHOW_LOAD_STREAM_BUTTON)
+ SHOW_LOAD_STREAM_BUTTON
+ )
detailsBinding.showStreamBrowser = viewModel.getButtonVisibilityPreference(
- SHOW_STREAM_BROWSER_BUTTON)
+ SHOW_STREAM_BROWSER_BUTTON
+ )
viewModel.streamLiveData.observe(
viewLifecycleOwner
@@ -172,38 +204,18 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
return detailsBinding.root
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.download_details_bar, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.delete -> {
- val dialog = DeleteDialogFragment()
- val bundle = Bundle()
- val title = getString(R.string.delete_item_title_format, args.details.filename)
- bundle.putString("title", title)
- dialog.arguments = bundle
- dialog.show(parentFragmentManager, "DeleteDialogFragment")
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onCopyClick(text: String) {
copyToClipboard("Real-Debrid Download Link", text)
context?.showToast(R.string.link_copied)
}
override fun onOpenClick(url: String) {
- openExternalWebPage(url)
+ try {
+ context?.openExternalWebPage(url)
+ } catch (e: ActivityNotFoundException) {
+ Timber.e("Error opening externally a link ${e.message}")
+ context?.showToast(R.string.no_supported_player_found)
+ }
}
override fun onOpenWithKodi(url: String) {
@@ -220,56 +232,22 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
}
override fun onBrowserStreamsClick(id: String) {
- openExternalWebPage(RD_STREAMING_URL + id)
- }
-
- override fun onDownloadClick(link: String, fileName: String) {
-
- when (Build.VERSION.SDK_INT) {
- 22 -> {
- downloadFile(link, fileName)
- }
- in 23..28 -> {
- requestPermissionLauncher.launch(
- Manifest.permission.WRITE_EXTERNAL_STORAGE
- )
- }
- else -> {
- downloadFile(link, fileName)
- }
+ try {
+ context?.openExternalWebPage(RD_STREAMING_URL + id)
+ } catch (e: ActivityNotFoundException) {
+ context?.showToast(R.string.browser_not_found)
}
}
- private fun downloadFile(link: String, fileName: String) {
- val manager =
- requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- val queuedDownload = manager.downloadFile(
- link = link,
- title = fileName,
- description = getString(R.string.app_name)
- )
- when (queuedDownload) {
- is EitherResult.Failure -> {
- context?.showToast(R.string.download_not_started)
- }
- is EitherResult.Success -> {
- context?.showToast(R.string.download_started)
- }
+ private val tempProgressListener = object : ProgressCallback {
+ override fun onProgress(progress: Double) {
+ Timber.d("Progress: $progress")
}
}
- private val requestPermissionLauncher =
- registerForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { isGranted: Boolean ->
- if (isGranted) {
- val link = args.details.download
- val fileName = args.details.filename
- downloadFile(link, fileName)
- } else {
- context?.showToast(R.string.needs_download_permission)
- }
- }
+ override fun onDownloadClick(link: String, fileName: String) {
+ activityViewModel.enqueueDownload(link, fileName)
+ }
override fun onShareClick(url: String) {
val shareIntent = Intent(Intent.ACTION_SEND)
@@ -279,21 +257,25 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
}
private fun tryStartExternalApp(intent: Intent) {
-
try {
startActivity(intent)
- } catch (e: android.content.ActivityNotFoundException) {
+ } catch (e: ActivityNotFoundException) {
context?.showToast(R.string.app_not_installed)
}
}
- private fun createMediaIntent(appPackage: String, url: String, component: ComponentName? = null, dataType: String = "video/*"): Intent {
+ private fun createMediaIntent(
+ appPackage: String,
+ url: String,
+ component: ComponentName? = null,
+ dataType: String = "video/*"
+ ): Intent {
val uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW)
intent.setPackage(appPackage)
intent.setDataAndTypeAndNormalize(uri, dataType)
- if (component!=null)
+ if (component != null)
intent.component = component
return intent
@@ -302,10 +284,13 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
override fun onSendToPlayer(url: String) {
when (viewModel.getDefaultPlayer()) {
"vlc" -> {
- val vlcIntent = createMediaIntent("org.videolan.vlc", url, ComponentName(
- "org.videolan.vlc",
- "org.videolan.vlc.gui.video.VideoPlayerActivity"
- ))
+ val vlcIntent = createMediaIntent(
+ "org.videolan.vlc", url,
+ ComponentName(
+ "org.videolan.vlc",
+ "org.videolan.vlc.gui.video.VideoPlayerActivity"
+ )
+ )
tryStartExternalApp(vlcIntent)
}
@@ -318,7 +303,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
try {
startActivity(mxIntent)
- } catch (e: android.content.ActivityNotFoundException) {
+ } catch (e: ActivityNotFoundException) {
mxIntent.setPackage("com.mxtech.videoplayer.ad")
tryStartExternalApp(mxIntent)
}
@@ -329,7 +314,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
}
"custom_player" -> {
val customPlayerPackage = viewModel.getCustomPlayerPreference()
- if (customPlayerPackage.isNullOrBlank()) {
+ if (customPlayerPackage.isBlank()) {
context?.showToast(R.string.invalid_package)
} else {
val customIntent = createMediaIntent(customPlayerPackage, url)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt
index 8aa68b902..0e612d667 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt
@@ -1,13 +1,9 @@
package com.github.livingwithhippos.unchained.downloaddetails.viewmodel
-import android.content.Intent
import android.content.SharedPreferences
-import android.net.Uri
-import androidx.core.app.ActivityCompat.startActivityForResult
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.Stream
import com.github.livingwithhippos.unchained.data.repository.DownloadRepository
import com.github.livingwithhippos.unchained.data.repository.KodiDeviceRepository
@@ -28,7 +24,6 @@ class DownloadDetailsViewModel @Inject constructor(
private val preferences: SharedPreferences,
private val streamingRepository: StreamingRepository,
private val downloadRepository: DownloadRepository,
- private val protoStore: ProtoStore,
private val kodiRepository: KodiRepository,
private val kodiDeviceRepository: KodiDeviceRepository,
) : ViewModel() {
@@ -39,18 +34,14 @@ class DownloadDetailsViewModel @Inject constructor(
fun fetchStreamingInfo(id: String) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- if (token.isBlank())
- throw IllegalArgumentException("Loaded token was empty: $token")
- val streamingInfo = streamingRepository.getStreams(token, id)
+ val streamingInfo = streamingRepository.getStreams(id)
streamLiveData.postValue(streamingInfo)
}
}
fun deleteDownload(id: String) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- val deleted = downloadRepository.deleteDownload(token, id)
+ val deleted = downloadRepository.deleteDownload(id)
if (deleted == null)
deletedDownloadLiveData.postEvent(-1)
else
@@ -62,7 +53,13 @@ class DownloadDetailsViewModel @Inject constructor(
viewModelScope.launch {
val device = kodiDeviceRepository.getDefault()
if (device != null) {
- val response = kodiRepository.openUrl(device.address, device.port, url, device.username, device.password)
+ val response = kodiRepository.openUrl(
+ device.address,
+ device.port,
+ url,
+ device.username,
+ device.password
+ )
if (response != null)
messageLiveData.postEvent(DownloadDetailsMessage.KodiSuccess)
else
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/model/FolderItemAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/model/FolderItemAdapter.kt
index 939b3c95f..eaaf33c59 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/model/FolderItemAdapter.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/model/FolderItemAdapter.kt
@@ -38,4 +38,4 @@ class FolderKeyProvider(private val adapter: FolderItemAdapter) :
override fun getPosition(key: DownloadItem): Int {
return adapter.currentList.indexOfFirst { it.id == key.id }
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt
index 7ac0e1c58..d66a71ccc 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt
@@ -1,7 +1,5 @@
package com.github.livingwithhippos.unchained.folderlist.view
-import android.app.DownloadManager
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
@@ -12,9 +10,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
import androidx.core.widget.addTextChangedListener
-import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@@ -23,6 +23,7 @@ import androidx.recyclerview.selection.SelectionTracker
import androidx.recyclerview.selection.StorageStrategy
import androidx.recyclerview.widget.RecyclerView
import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.base.UnchainedFragment
import com.github.livingwithhippos.unchained.data.model.APIError
import com.github.livingwithhippos.unchained.data.model.ApiConversionError
import com.github.livingwithhippos.unchained.data.model.DownloadItem
@@ -35,54 +36,29 @@ import com.github.livingwithhippos.unchained.folderlist.viewmodel.FolderListView
import com.github.livingwithhippos.unchained.lists.view.DownloadListListener
import com.github.livingwithhippos.unchained.lists.view.SelectedItemsButtonsListener
import com.github.livingwithhippos.unchained.utilities.DataBindingDetailsLookup
-import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.extension.copyToClipboard
import com.github.livingwithhippos.unchained.utilities.extension.delayedScrolling
-import com.github.livingwithhippos.unchained.utilities.extension.downloadFile
import com.github.livingwithhippos.unchained.utilities.extension.getThemedDrawable
import com.github.livingwithhippos.unchained.utilities.extension.showToast
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
-class FolderListFragment : Fragment(), DownloadListListener {
+class FolderListFragment : UnchainedFragment(), DownloadListListener {
private val viewModel: FolderListViewModel by viewModels()
private val args: FolderListFragmentArgs by navArgs()
+ private val job = Job()
+ private val scope = CoroutineScope(Dispatchers.Default + job)
+
private val mediaRegex =
"\\.(webm|avi|mkv|ogg|MTS|M2TS|TS|mov|wmv|mp4|m4p|m4v|mp2|mpe|mpv|mpg|mpeg|m2v|3gp)$".toRegex()
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.folder_bar, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.download_all -> {
- downloadAll()
- true
- }
- R.id.share_all -> {
- shareAll()
- true
- }
- R.id.copy_all -> {
- copyAll()
- true
- }
-
- else -> super.onOptionsItemSelected(item)
- }
- }
-
private fun shareAll() {
val downloads: List? = viewModel.folderLiveData.value?.peekContent()
@@ -118,32 +94,7 @@ class FolderListFragment : Fragment(), DownloadListListener {
private fun downloadAll() {
val downloads: List? = viewModel.folderLiveData.value?.peekContent()
if (!downloads.isNullOrEmpty()) {
- var downloadStarted = false
- val manager =
- requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- downloads.forEach {
- val queuedDownload = manager.downloadFile(
- it.download,
- it.filename,
- getString(R.string.app_name)
- )
- when (queuedDownload) {
- is EitherResult.Failure -> {
- context?.showToast(
- getString(
- R.string.download_not_started_format,
- it.filename
- )
- )
- }
- is EitherResult.Success -> {
- downloadStarted = true
- }
- }
- }
-
- if (downloadStarted)
- context?.showToast(R.string.download_started)
+ activityViewModel.enqueueDownloads(downloads)
}
}
@@ -155,6 +106,37 @@ class FolderListFragment : Fragment(), DownloadListListener {
val binding = FragmentFolderListBinding.inflate(inflater, container, false)
setup(binding)
+
+ val menuHost: MenuHost = requireActivity()
+
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.folder_bar, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.download_all -> {
+ downloadAll()
+ true
+ }
+ R.id.share_all -> {
+ shareAll()
+ true
+ }
+ R.id.copy_all -> {
+ copyAll()
+ true
+ }
+
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner, Lifecycle.State.RESUMED
+ )
+
return binding.root
}
@@ -226,35 +208,23 @@ class FolderListFragment : Fragment(), DownloadListListener {
}
override fun downloadSelectedItems() {
- if (linkTracker.selection.toList().isNotEmpty()) {
- var downloadStarted = false
- val manager =
- requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- linkTracker.selection.forEach { item ->
- val queuedDownload = manager.downloadFile(
- item.download,
- item.filename,
- getString(R.string.app_name),
+ val downloads: List = linkTracker.selection.toList()
+ if (downloads.isNotEmpty()) {
+ if (downloads.size == 1) {
+ activityViewModel.enqueueDownload(
+ downloads.first().download,
+ downloads.first().filename
)
- when (queuedDownload) {
- is EitherResult.Failure -> {
- context?.showToast(
- getString(
- R.string.download_not_started_format,
- item.filename
- )
- )
- }
- is EitherResult.Success -> {
- downloadStarted = true
- }
- }
+ } else {
+ activityViewModel.enqueueDownloads(downloads)
}
- if (downloadStarted)
- context?.showToast(R.string.download_started)
} else
context?.showToast(R.string.select_one_item)
}
+
+ override fun openSelectedDetails() {
+ // not implemented at the moment
+ }
}
binding.cbSelectAll.setOnCheckedChangeListener { _, isChecked ->
@@ -265,7 +235,6 @@ class FolderListFragment : Fragment(), DownloadListListener {
}
}
-
viewModel.deletedDownloadLiveData.observe(
viewLifecycleOwner
) {
@@ -282,11 +251,13 @@ class FolderListFragment : Fragment(), DownloadListListener {
// observe the list loading status
viewModel.folderLiveData.observe(viewLifecycleOwner) {
- // todo: if I use just getContent() I can restore on reload?
it.getContentIfNotHandled()?.let { _ ->
updateList(adapter)
- lifecycleScope.launch {
- binding.rvFolderList.delayedScrolling(requireContext(), delay = 500)
+ // scroll only if the results are still coming in
+ if (viewModel.getScrollingAllowed()) {
+ lifecycleScope.launch {
+ binding.rvFolderList.delayedScrolling(requireContext(), delay = 500)
+ }
}
}
}
@@ -319,9 +290,6 @@ class FolderListFragment : Fragment(), DownloadListListener {
viewModel.queryLiveData.observe(viewLifecycleOwner) {
updateList(adapter)
- lifecycleScope.launch {
- binding.rvFolderList.delayedScrolling(requireContext())
- }
}
binding.cbFilterSize.setOnCheckedChangeListener { _, isChecked ->
@@ -346,7 +314,7 @@ class FolderListFragment : Fragment(), DownloadListListener {
binding.sortingButton.background = requireContext().getThemedDrawable(sortDrawableID)
binding.sortingButton.setOnClickListener {
- showSortingPopup(it, R.menu.sorting_popup, adapter, binding.rvFolderList)
+ showSortingPopup(it, R.menu.folder_sorting_popup, adapter, binding.rvFolderList)
}
// load all the links
@@ -362,6 +330,11 @@ class FolderListFragment : Fragment(), DownloadListListener {
}
}
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.setScrollingAllowed(true)
+ }
+
private fun showSortingPopup(
v: View,
@MenuRes menuRes: Int,
@@ -495,6 +468,7 @@ class FolderListFragment : Fragment(), DownloadListListener {
}
override fun onClick(item: DownloadItem) {
+ viewModel.setScrollingAllowed(false)
val action =
FolderListFragmentDirections.actionFolderListFragmentToDownloadDetailsDest(item)
findNavController().navigate(action)
@@ -506,5 +480,6 @@ class FolderListFragment : Fragment(), DownloadListListener {
const val TAG_SORT_ZA = "sort_za_tag"
const val TAG_SORT_SIZE_ASC = "sort_size_asc_tag"
const val TAG_SORT_SIZE_DESC = "sort_size_desc_tag"
+ const val TAG_SORT_SEEDERS = "sort_seeders_tag"
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt
index 64ec35be9..ae1f5430e 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt
@@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
import com.github.livingwithhippos.unchained.data.repository.DownloadRepository
@@ -17,6 +16,7 @@ import com.github.livingwithhippos.unchained.utilities.postEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,7 +25,6 @@ class FolderListViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val preferences: SharedPreferences,
private val unrestrictRepository: UnrestrictRepository,
- private val protoStore: ProtoStore,
private val downloadRepository: DownloadRepository
) : ViewModel() {
@@ -46,10 +45,9 @@ class FolderListViewModel @Inject constructor(
fun retrieveFolderFileList(folderLink: String) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
val filesList: EitherResult> =
- unrestrictRepository.getEitherFolderLinks(token, folderLink)
+ unrestrictRepository.getEitherFolderLinks(folderLink)
when (filesList) {
is EitherResult.Failure -> errorsLiveData.postEvent(filesList.failure)
@@ -61,7 +59,6 @@ class FolderListViewModel @Inject constructor(
fun retrieveFiles(links: List) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
// either first time or there were some errors, re-download
if (links.size != getRetrievedLinks()) {
@@ -70,7 +67,7 @@ class FolderListViewModel @Inject constructor(
links.forEachIndexed { index, link ->
when (
val file =
- unrestrictRepository.getEitherUnrestrictedLink(token, link)
+ unrestrictRepository.getEitherUnrestrictedLink(link)
) {
is EitherResult.Failure -> {
errorsLiveData.postEvent(file.failure)
@@ -103,9 +100,8 @@ class FolderListViewModel @Inject constructor(
fun deleteDownloadList(downloads: List) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
downloads.forEach {
- val deleted = downloadRepository.deleteDownload(token, it.id)
+ val deleted = downloadRepository.deleteDownload(it.id)
if (deleted != null)
deletedDownloadLiveData.postEvent(it)
}
@@ -118,7 +114,8 @@ class FolderListViewModel @Inject constructor(
queryJob = viewModelScope.launch {
delay(500)
- queryLiveData.postValue(query?.trim() ?: "")
+ if (isActive)
+ queryLiveData.postValue(query?.trim() ?: "")
}
}
@@ -162,12 +159,23 @@ class FolderListViewModel @Inject constructor(
?: FolderListFragment.TAG_DEFAULT_SORT
}
+ fun setScrollingAllowed(allow: Boolean) {
+ with(preferences.edit()) {
+ putBoolean(KEY_ALLOW_SCROLLING, allow)
+ apply()
+ }
+ }
+
+ fun getScrollingAllowed(): Boolean {
+ return preferences.getBoolean(KEY_ALLOW_SCROLLING, true)
+ }
+
companion object {
+ const val KEY_ALLOW_SCROLLING = "allow_scrolling"
const val KEY_RETRIEVED_LINKS = "retrieve_links"
const val KEY_LIST_FILTER_SIZE = "filter_list_size"
const val KEY_LIST_FILTER_TYPE = "filter_list_type"
const val KEY_LIST_SORTING = "sort_list_type"
const val KEY_SHOW_FOLDER_FILTERS = "show_folders_filters"
-
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt
index 4218df085..1fde1b7df 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt
@@ -2,7 +2,6 @@ package com.github.livingwithhippos.unchained.lists.model
import androidx.paging.PagingSource
import androidx.paging.PagingState
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.repository.DownloadRepository
import retrofit2.HttpException
@@ -12,22 +11,18 @@ private const val DOWNLOAD_STARTING_PAGE_INDEX = 1
class DownloadPagingSource(
private val downloadRepository: DownloadRepository,
- private val protoStore: ProtoStore,
private val query: String,
) : PagingSource() {
override suspend fun load(params: LoadParams): LoadResult {
val page = params.key ?: DOWNLOAD_STARTING_PAGE_INDEX
- val token = protoStore.getCredentials().accessToken
- if (token.length < 5)
- throw IllegalArgumentException("Error loading token: $token")
return try {
val response =
if (query.isBlank())
- downloadRepository.getDownloads(token, null, page, params.loadSize)
+ downloadRepository.getDownloads(null, page, params.loadSize)
else
- downloadRepository.getDownloads(token, null, page, params.loadSize)
+ downloadRepository.getDownloads(null, page, params.loadSize)
.filter { it.filename.contains(query, ignoreCase = true) }
LoadResult.Page(
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt
index e59ac88db..2ae24a7f8 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt
@@ -1,6 +1,5 @@
import androidx.paging.PagingSource
import androidx.paging.PagingState
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
import retrofit2.HttpException
@@ -13,22 +12,18 @@ private const val TORRENT_STARTING_PAGE_INDEX = 1
*/
class TorrentPagingSource(
private val torrentsRepository: TorrentsRepository,
- private val protoStore: ProtoStore,
private val query: String,
) : PagingSource() {
override suspend fun load(params: LoadParams): LoadResult {
val page = params.key ?: TORRENT_STARTING_PAGE_INDEX
- val token = protoStore.getCredentials().accessToken
- if (token.length < 5)
- throw IllegalArgumentException("Error loading token: $token")
return try {
val response =
if (query.isBlank())
- torrentsRepository.getTorrentsList(token, null, page, params.loadSize)
+ torrentsRepository.getTorrentsList(null, page, params.loadSize)
else
- torrentsRepository.getTorrentsList(token, null, page, params.loadSize)
+ torrentsRepository.getTorrentsList(null, page, params.loadSize)
.filter { it.filename.contains(query, ignoreCase = true) }
LoadResult.Page(
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt
index ca9cdb3e2..8b501c580 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt
@@ -1,7 +1,5 @@
package com.github.livingwithhippos.unchained.lists.view
-import android.app.DownloadManager
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
@@ -11,9 +9,11 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
-import androidx.fragment.app.setFragmentResultListener
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
@@ -48,11 +48,10 @@ import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuth
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationState
import com.github.livingwithhippos.unchained.utilities.DOWNLOADS_TAB
import com.github.livingwithhippos.unchained.utilities.DataBindingDetailsLookup
-import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.EventObserver
import com.github.livingwithhippos.unchained.utilities.TORRENTS_TAB
+import com.github.livingwithhippos.unchained.utilities.beforeSelectionStatusList
import com.github.livingwithhippos.unchained.utilities.extension.delayedScrolling
-import com.github.livingwithhippos.unchained.utilities.extension.downloadFile
import com.github.livingwithhippos.unchained.utilities.extension.getApiErrorMessage
import com.github.livingwithhippos.unchained.utilities.extension.getDownloadedFileUri
import com.github.livingwithhippos.unchained.utilities.extension.getThemedDrawable
@@ -63,7 +62,9 @@ import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import timber.log.Timber
/**
* A simple [UnchainedFragment] subclass.
@@ -85,6 +86,58 @@ class ListsTabFragment : UnchainedFragment() {
val binding: FragmentTabListsBinding =
FragmentTabListsBinding.inflate(inflater, container, false)
+ val menuHost: MenuHost = requireActivity()
+
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.lists_bar, menu)
+
+ val searchItem = menu.findItem(R.id.search)
+ val searchView = searchItem.actionView as SearchView
+ // listens to the user typing in the search bar
+
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ // since there is a 500ms delay on new queries, this will help if the user types something and press search in less than half sec. May be unnecessary. The value is checked anyway in the ViewModel to avoid reloading with the same query as the last one.
+ override fun onQueryTextSubmit(query: String?): Boolean {
+ viewModel.setListFilter(query)
+ return true
+ }
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ // simulate debounce
+ queryJob?.cancel()
+
+ queryJob = lifecycleScope.launch {
+ delay(500)
+ if (isActive)
+ viewModel.setListFilter(newText)
+ }
+ return true
+ }
+ })
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.search -> {
+ true
+ }
+ R.id.delete_all_downloads -> {
+ showDeleteAllDialog(DOWNLOADS_TAB)
+ true
+ }
+ R.id.delete_all_torrents -> {
+ showDeleteAllDialog(TORRENTS_TAB)
+ true
+ }
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner, Lifecycle.State.RESUMED
+ )
+
val listsAdapter = ListsAdapter(this)
binding.listPager.adapter = listsAdapter
@@ -174,12 +227,12 @@ class ListsTabFragment : UnchainedFragment() {
)
findNavController().navigate(action)
} else
- viewModel.downloadTorrent(event.item)
+ viewModel.unrestrictTorrent(event.item)
}
// open the torrent details fragment
else -> {
val action =
- ListsTabFragmentDirections.actionListsTabToTorrentDetails(event.item.id)
+ ListsTabFragmentDirections.actionListsTabToTorrentDetails(event.item)
var loop = 0
val controller = findNavController()
@@ -195,7 +248,7 @@ class ListsTabFragment : UnchainedFragment() {
}
is ListEvent.OpenTorrent -> {
val action =
- ListsTabFragmentDirections.actionListsTabToTorrentDetails(event.id)
+ ListsTabFragmentDirections.actionListsTabToTorrentDetails(event.item)
// workaround to avoid issues when the dialog still hasn't been popped from the navigation stack
val controller = findNavController()
@@ -219,11 +272,9 @@ class ListsTabFragment : UnchainedFragment() {
}
}
}
-
}
)
-
viewModel.errorsLiveData.observe(
viewLifecycleOwner,
EventObserver {
@@ -276,57 +327,6 @@ class ListsTabFragment : UnchainedFragment() {
super.onViewCreated(view, savedInstanceState)
}
- // menu-related functions
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.lists_bar, menu)
- super.onCreateOptionsMenu(menu, inflater)
-
- val searchItem = menu.findItem(R.id.search)
- val searchView = searchItem.actionView as SearchView
- // listens to the user typing in the search bar
-
- searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
- // since there is a 500ms delay on new queries, this will help if the user types something and press search in less than half sec. May be unnecessary. The value is checked anyway in the ViewModel to avoid reloading with the same query as the last one.
- override fun onQueryTextSubmit(query: String?): Boolean {
- viewModel.setListFilter(query)
- return true
- }
-
- override fun onQueryTextChange(newText: String?): Boolean {
- // simulate debounce
- queryJob?.cancel()
-
- queryJob = lifecycleScope.launch {
- delay(500)
- viewModel.setListFilter(newText)
- }
- return true
- }
- })
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.search -> {
- true
- }
- R.id.delete_all_downloads -> {
- showDeleteAllDialog(DOWNLOADS_TAB)
- true
- }
- R.id.delete_all_torrents -> {
- showDeleteAllDialog(TORRENTS_TAB)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
private fun showDeleteAllDialog(selectedTab: Int) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.delete_all))
@@ -429,35 +429,23 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener {
}
override fun downloadSelectedItems() {
- if (downloadTracker.selection.toList().isNotEmpty()) {
- var downloadStarted = false
- val manager =
- requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- downloadTracker.selection.forEach { item ->
- val queuedDownload = manager.downloadFile(
- item.download,
- item.filename,
- getString(R.string.app_name),
+ val downloads: List = downloadTracker.selection.toList()
+ if (downloads.isNotEmpty()) {
+ if (downloads.size == 1) {
+ activityViewModel.enqueueDownload(
+ downloads.first().download,
+ downloads.first().filename
)
- when (queuedDownload) {
- is EitherResult.Failure -> {
- context?.showToast(
- getString(
- R.string.download_not_started_format,
- item.filename
- )
- )
- }
- is EitherResult.Success -> {
- downloadStarted = true
- }
- }
+ } else {
+ activityViewModel.enqueueDownloads(downloads)
}
- if (downloadStarted)
- context?.showToast(R.string.download_started)
} else
context?.showToast(R.string.select_one_item)
}
+
+ override fun openSelectedDetails() {
+ // used only in torrent view
+ }
}
binding.cbSelectAll.setOnCheckedChangeListener { _, isChecked ->
@@ -512,7 +500,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener {
viewLifecycleOwner,
EventObserver { links ->
// todo: if it gets emptied null/empty should be processed too
- if (!links.isNullOrEmpty()) {
+ if (links.isNotEmpty()) {
// simulate list refresh
binding.srLayout.isRefreshing = true
// refresh items, when returned they'll stop the animation
@@ -541,15 +529,6 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener {
}
)
- setFragmentResultListener("downloadActionKey") { _, bundle ->
- bundle.getString("deletedDownloadKey")?.let {
- viewModel.deleteDownload(it)
- }
- bundle.getParcelable("openedDownloadItem")?.let {
- onClick(it)
- }
- }
-
viewModel.deletedDownloadLiveData.observe(
viewLifecycleOwner,
EventObserver {
@@ -630,6 +609,11 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener {
override fun onSelectionChanged() {
super.onSelectionChanged()
binding.selectedTorrents = torrentTracker.selection.size()
+ if (torrentTracker.selection.size() == 1) {
+ binding.bDetailsSelected.visibility = View.VISIBLE
+ } else {
+ binding.bDetailsSelected.visibility = View.GONE
+ }
}
})
@@ -652,6 +636,20 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener {
} else
context?.showToast(R.string.select_one_item)
}
+
+ override fun openSelectedDetails() {
+ if (torrentTracker.selection.toList().size == 1) {
+ val item: TorrentItem = torrentTracker.selection.toList().first()
+ val action = if (beforeSelectionStatusList.contains(item.status))
+ ListsTabFragmentDirections.actionListTabsDestToTorrentProcessingFragment(
+ torrentID = item.id
+ )
+ else
+ ListsTabFragmentDirections.actionListsTabToTorrentDetails(item)
+ findNavController().navigate(action)
+ } else
+ Timber.e("Somehow user triggered openSelectedDetails with a selection size of ${torrentTracker.selection.toList().size}")
+ }
}
binding.cbSelectAll.setOnCheckedChangeListener { _, isChecked ->
@@ -746,27 +744,58 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener {
}
)
- setFragmentResultListener("torrentActionKey") { _, bundle ->
- bundle.getString("deletedTorrentKey")?.let {
- viewModel.deleteTorrent(it)
- }
- bundle.getString("openedTorrentItem")?.let {
- viewModel.postEventNotice(ListEvent.OpenTorrent(it))
- }
- bundle.getParcelable("downloadedTorrentItem")?.let {
- onClick(it)
- }
- }
-
return binding.root
}
override fun onClick(item: TorrentItem) {
- viewModel.postEventNotice(ListEvent.TorrentItemClick(item))
- }
-}
+ // this was used to wait for some loading, can't remember the use case anymore
+ // maybe a link share or a notification click?
+ var loop = 0
+
+ lifecycleScope.launch {
+ val controller = findNavController()
+ while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
+ delay(100)
+ }
+
+ if (item.status == "downloaded") {
+ // unrestrict and move to download tab
+ if (item.links.size > 1) {
+ val action =
+ ListsTabFragmentDirections.actionListTabsDestToFolderListFragment2(
+ folder = null,
+ torrent = item,
+ linkList = null
+ )
+ if (controller.currentDestination?.id == R.id.list_tabs_dest)
+ controller.navigate(action)
+ else
+ Timber.e("Correct tab was not ready within 2 seconds after clicking torrent $item")
+ } else
+ viewModel.unrestrictTorrent(item)
+ } else if (beforeSelectionStatusList.contains(item.status)) {
+ // go to torrent processing since it is still loading
+ val action =
+ ListsTabFragmentDirections.actionListTabsDestToTorrentProcessingFragment(
+ torrentID = item.id
+ )
+ if (controller.currentDestination?.id == R.id.list_tabs_dest)
+ controller.navigate(action)
+ else
+ Timber.e("Correct tab was not ready within 2 seconds after clicking torrent $item")
+ } else {
+ // go to torrent details
+ val action = ListsTabFragmentDirections.actionListsTabToTorrentDetails(item)
+ if (controller.currentDestination?.id == R.id.list_tabs_dest)
+ controller.navigate(action)
+ else
+ Timber.e("Correct tab was not ready within 2 seconds after clicking torrent $item")
+ }
+ }
+ }
+}
sealed class ListState {
object UpdateTorrent : ListState()
@@ -778,4 +807,5 @@ interface SelectedItemsButtonsListener {
fun deleteSelectedItems()
fun shareSelectedItems()
fun downloadSelectedItems()
+ fun openSelectedDetails()
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/TorrentListPagingAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/TorrentListPagingAdapter.kt
index d596cfe81..7cb504ca7 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/TorrentListPagingAdapter.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/TorrentListPagingAdapter.kt
@@ -15,9 +15,11 @@ class TorrentListPagingAdapter(listener: TorrentListListener) :
override fun areContentsTheSame(oldItem: TorrentItem, newItem: TorrentItem): Boolean {
// check the torrent progress
- return oldItem.progress == oldItem.progress &&
+ return oldItem.progress == newItem.progress &&
// check the torrent status
- oldItem.status == oldItem.status
+ oldItem.status == newItem.status &&
+ // may be triggered by different cache
+ oldItem.bytes == newItem.bytes
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt
index 840c6386e..4b0fea453 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt
@@ -12,7 +12,6 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.liveData
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
@@ -37,7 +36,6 @@ class ListTabsViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val downloadRepository: DownloadRepository,
private val torrentsRepository: TorrentsRepository,
- private val protoStore: ProtoStore,
private val unrestrictRepository: UnrestrictRepository
) : ViewModel() {
@@ -48,14 +46,14 @@ class ListTabsViewModel @Inject constructor(
val downloadsLiveData: LiveData> =
Transformations.switchMap(queryLiveData) { query: String ->
Pager(PagingConfig(pageSize = 50, initialLoadSize = 100)) {
- DownloadPagingSource(downloadRepository, protoStore, query)
+ DownloadPagingSource(downloadRepository, query)
}.liveData.cachedIn(viewModelScope)
}
val torrentsLiveData: LiveData> =
Transformations.switchMap(queryLiveData) { query: String ->
Pager(PagingConfig(pageSize = 50, initialLoadSize = 100)) {
- TorrentPagingSource(torrentsRepository, protoStore, query)
+ TorrentPagingSource(torrentsRepository, query)
}.liveData.cachedIn(viewModelScope)
}
@@ -68,10 +66,14 @@ class ListTabsViewModel @Inject constructor(
val eventLiveData = MutableLiveData>()
- fun downloadTorrent(torrent: TorrentItem) {
+ /**
+ * Un restrict a torrent and move it to the download section
+ *
+ * @param torrent
+ */
+ fun unrestrictTorrent(torrent: TorrentItem) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- val items = unrestrictRepository.getUnrestrictedLinkList(token, torrent.links)
+ val items = unrestrictRepository.getUnrestrictedLinkList(torrent.links)
val values =
items.filterIsInstance>().map { it.success }
val errors =
@@ -86,8 +88,7 @@ class ListTabsViewModel @Inject constructor(
fun downloadTorrentFolder(torrent: TorrentItem) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- val items = unrestrictRepository.getUnrestrictedLinkList(token, torrent.links)
+ val items = unrestrictRepository.getUnrestrictedLinkList(torrent.links)
val values =
items.filterIsInstance>().map { it.success }
val errors =
@@ -102,8 +103,7 @@ class ListTabsViewModel @Inject constructor(
fun deleteTorrent(id: String) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- val deleted = torrentsRepository.deleteTorrent(token, id)
+ val deleted = torrentsRepository.deleteTorrent(id)
when (deleted) {
is EitherResult.Failure -> {
errorsLiveData.postEvent(listOf(deleted.failure))
@@ -118,8 +118,7 @@ class ListTabsViewModel @Inject constructor(
fun deleteDownload(id: String) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- val deleted = downloadRepository.deleteDownload(token, id)
+ val deleted = downloadRepository.deleteDownload(id)
if (deleted == null)
deletedDownloadLiveData.postEvent(DOWNLOAD_NOT_DELETED)
else
@@ -146,10 +145,9 @@ class ListTabsViewModel @Inject constructor(
deletedDownloadLiveData.postEvent(0)
var page = 1
- val token = protoStore.getCredentials().accessToken
val completeDownloadList = mutableListOf()
do {
- val downloads = downloadRepository.getDownloads(token, 0, page++, 50)
+ val downloads = downloadRepository.getDownloads(0, page++, 50)
completeDownloadList.addAll(downloads)
} while (downloads.size >= 50)
@@ -158,7 +156,7 @@ class ListTabsViewModel @Inject constructor(
if (completeDownloadList.size / 10 < 15) 15 else completeDownloadList.size / 10
completeDownloadList.forEachIndexed { index, item ->
- downloadRepository.deleteDownload(token, item.id)
+ downloadRepository.deleteDownload(item.id)
if ((index + 1) % progressIndicator == 0)
deletedDownloadLiveData.postEvent(index + 1)
}
@@ -169,11 +167,10 @@ class ListTabsViewModel @Inject constructor(
fun deleteAllTorrents() {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
do {
- val torrents = torrentsRepository.getTorrentsList(token, 0, 1, 50)
+ val torrents = torrentsRepository.getTorrentsList(0, 1, 50)
torrents.forEach {
- torrentsRepository.deleteTorrent(token, it.id)
+ torrentsRepository.deleteTorrent(it.id)
}
} while (torrents.size >= 50)
@@ -183,9 +180,8 @@ class ListTabsViewModel @Inject constructor(
fun deleteTorrents(torrents: List) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
torrents.forEach {
- torrentsRepository.deleteTorrent(token, it.id)
+ torrentsRepository.deleteTorrent(it.id)
}
if (torrents.size > 1)
deletedTorrentLiveData.postEvent(TORRENTS_DELETED)
@@ -197,15 +193,14 @@ class ListTabsViewModel @Inject constructor(
fun downloadItems(torrents: List) {
torrents.filter { it.status == "downloaded" }
.forEach {
- downloadTorrent(it)
+ unrestrictTorrent(it)
}
}
fun deleteDownloads(downloads: List) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
downloads.forEach {
- downloadRepository.deleteDownload(token, it.id)
+ downloadRepository.deleteDownload(it.id)
}
if (downloads.size > 1)
deletedDownloadLiveData.postEvent(DOWNLOADS_DELETED)
@@ -234,6 +229,6 @@ class ListTabsViewModel @Inject constructor(
sealed class ListEvent {
data class DownloadItemClick(val item: DownloadItem) : ListEvent()
data class TorrentItemClick(val item: TorrentItem) : ListEvent()
- data class OpenTorrent(val id: String) : ListEvent()
+ data class OpenTorrent(val item: TorrentItem) : ListEvent()
data class SetTab(val tab: Int) : ListEvent()
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt
index bd66457b3..702578e72 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt
@@ -5,6 +5,7 @@ import android.content.ContentResolver.SCHEME_CONTENT
import android.content.ContentResolver.SCHEME_FILE
import android.net.Uri
import android.os.Bundle
+import android.provider.MediaStore
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -24,7 +25,6 @@ import com.github.livingwithhippos.unchained.data.model.NetworkError
import com.github.livingwithhippos.unchained.data.repository.DownloadResult
import com.github.livingwithhippos.unchained.databinding.NewDownloadFragmentBinding
import com.github.livingwithhippos.unchained.lists.view.ListState
-import com.github.livingwithhippos.unchained.lists.view.ListsTabFragment
import com.github.livingwithhippos.unchained.newdownload.viewmodel.Link
import com.github.livingwithhippos.unchained.newdownload.viewmodel.NewDownloadViewModel
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationEvent
@@ -59,7 +59,6 @@ import java.util.regex.Pattern
@AndroidEntryPoint
class NewDownloadFragment : UnchainedFragment() {
- // todo: switch to the navigation scoped ViewModel to manage the transition between fragments
// if we receive an intent and new download is already selected and showing a DownloadDetailsFragment, it may not trigger the observers in this class
private val viewModel: NewDownloadViewModel by viewModels()
@@ -81,7 +80,7 @@ class NewDownloadFragment : UnchainedFragment() {
private fun setupObservers(binding: NewDownloadFragmentBinding) {
- viewModel.linkLiveData.observe(
+ viewModel.downloadLiveData.observe(
viewLifecycleOwner,
EventObserver { linkDetails ->
// new download item, alert the list fragment that it needs updating
@@ -109,20 +108,7 @@ class NewDownloadFragment : UnchainedFragment() {
}
)
- viewModel.torrentLiveData.observe(
- viewLifecycleOwner,
- EventObserver { torrent ->
- // new torrent item, alert the list fragment that it needs updating
- activityViewModel.setListState(ListState.UpdateTorrent)
- val action =
- NewDownloadFragmentDirections.actionNewDownloadDestToTorrentDetailsFragment(
- torrent.id
- )
- findNavController().navigate(action)
- }
- )
-
- viewModel.containerLiveData.observe(
+ viewModel.linkLiveData.observe(
viewLifecycleOwner,
EventObserver { link ->
when (link) {
@@ -140,6 +126,13 @@ class NewDownloadFragment : UnchainedFragment() {
is Link.RetrievalError -> {
viewModel.postMessage(getString(R.string.error_parsing_container))
}
+ is Link.Torrent -> {
+ val action =
+ NewDownloadFragmentDirections.actionNewDownloadFragmentToTorrentProcessingFragment(
+ torrentID = link.upload.id
+ )
+ findNavController().navigate(action)
+ }
else -> {
}
}
@@ -244,16 +237,20 @@ class NewDownloadFragment : UnchainedFragment() {
private fun setupClickListeners(binding: NewDownloadFragmentBinding) {
// add the unrestrict button listener
binding.bUnrestrict.setOnClickListener {
-
- // todo: check if opening from the browser trigger the ExpiredToken option
val authState = activityViewModel.getAuthenticationMachineState()
if (authState is FSMAuthenticationState.AuthenticatedPrivateToken || authState is FSMAuthenticationState.AuthenticatedOpenToken) {
val link: String = binding.tiLink.text.toString().trim()
when {
- // this must be before the link.isWebUrl() check
+ // this must be before the link.isWebUrl() check or it won't trigger
link.isTorrent() -> {
- viewModel.postMessage(getString(R.string.loading_torrent))
- enableButtons(binding, false)
+ val action =
+ NewDownloadFragmentDirections.actionNewDownloadFragmentToTorrentProcessingFragment(
+ link = link
+ )
+ findNavController().navigate(action)
+
+ // viewModel.postMessage(getString(R.string.loading_torrent))
+ // enableButtons(binding, false)
/**
* DownloadManager does not support insecure (https) links anymore
* to add support for it, follow these instructions
@@ -264,7 +261,7 @@ class NewDownloadFragment : UnchainedFragment() {
) else link
downloadTorrent(Uri.parse(secureLink))
*/
- downloadTorrentToCache(binding, link)
+ // downloadTorrentToCache(binding, link)
}
link.isWebUrl() -> {
viewModel.postMessage(getString(R.string.loading_host_link))
@@ -285,9 +282,14 @@ class NewDownloadFragment : UnchainedFragment() {
)
}
link.isMagnet() -> {
- viewModel.postMessage(getString(R.string.loading_magnet_link))
- enableButtons(binding, false)
- viewModel.fetchAddedMagnet(link)
+ val action =
+ NewDownloadFragmentDirections.actionNewDownloadFragmentToTorrentProcessingFragment(
+ link = link
+ )
+ findNavController().navigate(action)
+ //viewModel.postMessage(getString(R.string.loading_magnet_link))
+ //enableButtons(binding, false)
+ //viewModel.fetchAddedMagnet(link)
}
link.isBlank() -> {
viewModel.postMessage(getString(R.string.please_insert_url))
@@ -296,6 +298,7 @@ class NewDownloadFragment : UnchainedFragment() {
viewModel.unrestrictContainer(link)
}
link.split("\n").firstOrNull()?.trim()?.isWebUrl() == true -> {
+ // todo: support list of magnets/torrents
val splitLinks: List =
link.split("\n").map { it.trim() }.filter { it.length > 10 }
viewModel.postMessage(getString(R.string.loading))
@@ -387,15 +390,46 @@ class NewDownloadFragment : UnchainedFragment() {
binding.bUnrestrict.performClick()
}
SCHEME_CONTENT, SCHEME_FILE -> {
- when {
- // check if it's a container
- CONTAINER_EXTENSION_PATTERN.toRegex().matches(link.path ?: "") -> {
- loadContainer(binding, link)
+
+ var handled = false
+
+ requireContext().contentResolver.query(
+ link,
+ arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
+ null,
+ null,
+ null
+ )?.use { metaCursor ->
+ if (metaCursor.moveToFirst()) {
+ val fileName = metaCursor.getString(0);
+ Timber.d("Torrent shared file found: $fileName")
+ when {
+ // check if it's a container
+ CONTAINER_EXTENSION_PATTERN.toRegex().matches(fileName) -> {
+ handled = true
+ loadContainer(binding, link)
+ }
+ fileName.endsWith(".torrent", ignoreCase = true) -> {
+ handled = true
+ loadTorrent(binding, link)
+ }
+ }
}
- link.path?.endsWith(".torrent", ignoreCase = true) == true -> {
- loadTorrent(binding, link)
+ }
+
+ if (!handled) {
+ when {
+ // check if it's a container
+ CONTAINER_EXTENSION_PATTERN.toRegex().matches(link.path ?: "") -> {
+ loadContainer(binding, link)
+ }
+ link.path?.endsWith(".torrent", ignoreCase = true) == true -> {
+ loadTorrent(binding, link)
+ }
+ else -> Timber.e("Unsupported content/file passed to NewDownloadFragment: $link")
}
- else -> Timber.e("Unsupported content/file passed to NewDownloadFragment")
+ } else {
+ // do nothing
}
}
SCHEME_HTTP, SCHEME_HTTPS -> {
@@ -415,22 +449,26 @@ class NewDownloadFragment : UnchainedFragment() {
}
}
- private fun loadTorrent(binding: NewDownloadFragmentBinding, uri: Uri) {
- // https://developer.android.com/training/data-storage/shared/documents-files#open
+ private fun loadCachedTorrent(
+ binding: NewDownloadFragmentBinding,
+ cacheDir: File,
+ fileName: String
+ ) {
try {
viewModel.postMessage(getString(R.string.loading_torrent_file))
- requireContext().contentResolver.openInputStream(uri)?.use { inputStream ->
+ val cacheFile = File(cacheDir, fileName)
+ cacheFile.inputStream().use { inputStream ->
val buffer: ByteArray = inputStream.readBytes()
viewModel.fetchUploadedTorrent(buffer)
}
} catch (exception: Exception) {
when (exception) {
- is IOException -> {
- Timber.e("Torrent conversion: IOException error getting the file: ${exception.message}")
- }
is java.io.FileNotFoundException -> {
Timber.e("Torrent conversion: file not found: ${exception.message}")
}
+ is IOException -> {
+ Timber.e("Torrent conversion: IOException error getting the file: ${exception.message}")
+ }
else -> {
Timber.e("Torrent conversion: Other error getting the file: ${exception.message}")
}
@@ -440,61 +478,57 @@ class NewDownloadFragment : UnchainedFragment() {
}
}
- private fun enableButtons(binding: NewDownloadFragmentBinding, enabled: Boolean = true) {
- binding.bUnrestrict.isEnabled = enabled
- binding.bUploadFile.isEnabled = enabled
- }
-
- private fun loadContainer(binding: NewDownloadFragmentBinding, uri: Uri) {
+ private fun loadTorrent(binding: NewDownloadFragmentBinding, uri: Uri) {
+ // https://developer.android.com/training/data-storage/shared/documents-files#open
try {
- viewModel.postMessage(getString(R.string.loading_container_file))
+ viewModel.postMessage(getString(R.string.loading_torrent_file))
requireContext().contentResolver.openInputStream(uri)?.use { inputStream ->
val buffer: ByteArray = inputStream.readBytes()
- viewModel.uploadContainer(buffer)
+ viewModel.fetchUploadedTorrent(buffer)
}
} catch (exception: Exception) {
when (exception) {
- is IOException -> {
- Timber.e("Container conversion: IOException error getting the file: ${exception.message}")
- }
is java.io.FileNotFoundException -> {
- Timber.e("Container conversion: file not found: ${exception.message}")
+ Timber.e("Torrent conversion: file not found: ${exception.message}")
+ }
+ is IOException -> {
+ Timber.e("Torrent conversion: IOException error getting the file: ${exception.message}")
}
else -> {
- Timber.e("Container conversion: Other error getting the file: ${exception.message}")
+ Timber.e("Torrent conversion: Other error getting the file: ${exception.message}")
}
}
enableButtons(binding, true)
- viewModel.postMessage(getString(R.string.error_loading_file))
+ viewModel.postMessage(getString(R.string.error_loading_torrent))
}
}
- private fun loadCachedTorrent(
- binding: NewDownloadFragmentBinding,
- cacheDir: File,
- fileName: String
- ) {
+ private fun enableButtons(binding: NewDownloadFragmentBinding, enabled: Boolean = true) {
+ binding.bUnrestrict.isEnabled = enabled
+ binding.bUploadFile.isEnabled = enabled
+ }
+
+ private fun loadContainer(binding: NewDownloadFragmentBinding, uri: Uri) {
try {
- viewModel.postMessage(getString(R.string.loading_torrent_file))
- val cacheFile = File(cacheDir, fileName)
- cacheFile.inputStream().use { inputStream ->
+ viewModel.postMessage(getString(R.string.loading_container_file))
+ requireContext().contentResolver.openInputStream(uri)?.use { inputStream ->
val buffer: ByteArray = inputStream.readBytes()
- viewModel.fetchUploadedTorrent(buffer)
+ viewModel.uploadContainer(buffer)
}
} catch (exception: Exception) {
when (exception) {
- is IOException -> {
- Timber.e("Torrent conversion: IOException error getting the file: ${exception.message}")
- }
is java.io.FileNotFoundException -> {
- Timber.e("Torrent conversion: file not found: ${exception.message}")
+ Timber.e("Container conversion: file not found: ${exception.message}")
+ }
+ is IOException -> {
+ Timber.e("Container conversion: IOException error getting the file: ${exception.message}")
}
else -> {
- Timber.e("Torrent conversion: Other error getting the file: ${exception.message}")
+ Timber.e("Container conversion: Other error getting the file: ${exception.message}")
}
}
enableButtons(binding, true)
- viewModel.postMessage(getString(R.string.error_loading_torrent))
+ viewModel.postMessage(getString(R.string.error_loading_file))
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt
index 88403ba86..3179b41df 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt
@@ -3,7 +3,6 @@ package com.github.livingwithhippos.unchained.newdownload.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
import com.github.livingwithhippos.unchained.data.model.UploadedTorrent
@@ -29,20 +28,17 @@ class NewDownloadViewModel @Inject constructor(
private val unrestrictRepository: UnrestrictRepository,
private val torrentsRepository: TorrentsRepository,
private val hostsRepository: HostsRepository,
- private val protoStore: ProtoStore,
) : ViewModel() {
// use Event since navigating back to this fragment would trigger this observable again
- val linkLiveData = MutableLiveData>()
+ val downloadLiveData = MutableLiveData>()
val folderLiveData = MutableLiveData>()
- val torrentLiveData = MutableLiveData>()
val networkExceptionLiveData = MutableLiveData>()
- val containerLiveData = MutableLiveData>()
+ val linkLiveData = MutableLiveData>()
val toastLiveData = MutableLiveData>()
fun fetchUnrestrictedLink(link: String, password: String?, remote: Int? = null) {
viewModelScope.launch {
- val token = getToken()
// check if it's a folder link
var isFolder = false
for (hostRegex in hostsRepository.getFoldersRegex()) {
@@ -55,10 +51,10 @@ class NewDownloadViewModel @Inject constructor(
}
if (!isFolder) {
val response =
- unrestrictRepository.getEitherUnrestrictedLink(token, link, password, remote)
+ unrestrictRepository.getEitherUnrestrictedLink(link, password, remote)
when (response) {
is EitherResult.Failure -> networkExceptionLiveData.postEvent(response.failure)
- is EitherResult.Success -> linkLiveData.postEvent(response.success)
+ is EitherResult.Success -> downloadLiveData.postEvent(response.success)
}
}
}
@@ -66,13 +62,12 @@ class NewDownloadViewModel @Inject constructor(
fun uploadContainer(container: ByteArray) {
viewModelScope.launch {
- val token = getToken()
- when (val fileList = unrestrictRepository.uploadContainer(token, container)) {
+ when (val fileList = unrestrictRepository.uploadContainer(container)) {
is EitherResult.Failure -> {
- networkExceptionLiveData.postEvent(fileList.failure)
+ networkExceptionLiveData.postEvent(fileList.failure)
}
is EitherResult.Success -> {
- containerLiveData.postEvent(Link.Container(fileList.success))
+ linkLiveData.postEvent(Link.Container(fileList.success))
}
}
}
@@ -80,65 +75,35 @@ class NewDownloadViewModel @Inject constructor(
fun unrestrictContainer(link: String) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- val links = unrestrictRepository.getContainerLinks(token, link)
+ val links = unrestrictRepository.getContainerLinks(link)
if (links != null)
- containerLiveData.postEvent(Link.Container(links))
+ linkLiveData.postEvent(Link.Container(links))
else
- containerLiveData.postEvent(Link.RetrievalError)
- }
- }
-
- fun fetchAddedMagnet(magnet: String) {
- viewModelScope.launch {
- val token = getToken()
- val availableHosts = torrentsRepository.getAvailableHosts(token)
- if (availableHosts.isNullOrEmpty()) {
- Timber.e("Error fetching available hosts")
- } else {
- val addedMagnet =
- torrentsRepository.addMagnet(token, magnet, availableHosts.first().host)
- when (addedMagnet) {
- is EitherResult.Failure -> {
- networkExceptionLiveData.postEvent(addedMagnet.failure)
- }
- is EitherResult.Success -> {
- torrentLiveData.postEvent(addedMagnet.success)
- }
- }
- }
+ linkLiveData.postEvent(Link.RetrievalError)
}
}
fun fetchUploadedTorrent(binaryTorrent: ByteArray) {
viewModelScope.launch {
- val token = getToken()
- val availableHosts = torrentsRepository.getAvailableHosts(token)
+ val availableHosts = torrentsRepository.getAvailableHosts()
if (availableHosts.isNullOrEmpty()) {
Timber.e("Error fetching available hosts")
} else {
val uploadedTorrent =
- torrentsRepository.addTorrent(token, binaryTorrent, availableHosts.first().host)
+ torrentsRepository.addTorrent(binaryTorrent, availableHosts.first().host)
when (uploadedTorrent) {
is EitherResult.Failure -> {
networkExceptionLiveData.postEvent(uploadedTorrent.failure)
}
is EitherResult.Success -> {
// todo: add checks for already chosen torrent/magnet (if possible), otherwise we get multiple downloads
- torrentLiveData.postEvent(uploadedTorrent.success)
+ linkLiveData.postEvent(Link.Torrent(uploadedTorrent.success))
}
}
}
}
}
- private suspend fun getToken(): String {
- val token = protoStore.getCredentials().accessToken
- if (token.isBlank())
- throw IllegalArgumentException("Loaded token was null or empty: $token")
- return token
- }
-
/**
* This function is used to manage multiple toast spawning from different parts of the logic
* to avoid the queue getting too long and a lot of messages being shown, see the collect on the fragment
@@ -152,7 +117,7 @@ sealed class Link {
data class Host(val link: String) : Link()
data class Folder(val link: String) : Link()
data class Magnet(val link: String) : Link()
- data class Torrent(val link: String) : Link()
+ data class Torrent(val upload: UploadedTorrent) : Link()
data class Container(val links: List) : Link()
object RetrievalError : Link()
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt
index 24b7949bd..a853fd812 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt
@@ -12,6 +12,7 @@ import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem
import com.github.livingwithhippos.unchained.plugins.model.TableParser
import com.github.livingwithhippos.unchained.settings.view.SettingsFragment.Companion.KEY_USE_DOH
import com.github.livingwithhippos.unchained.utilities.extension.removeWebFormatting
+import com.github.livingwithhippos.unchained.utilities.parseCommonSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
@@ -21,6 +22,7 @@ import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
+import org.jsoup.select.Elements
import timber.log.Timber
class Parser(
@@ -330,14 +332,14 @@ class Parser(
var size: String? = null
regexes.sizeRegex?.regexps?.forEach { regex ->
- val sizeSeeders = parseSingle(
+ val currSize = parseSingle(
regex,
source,
baseUrl
)
- if (!sizeSeeders.isNullOrBlank()) {
- size = sizeSeeders
+ if (!currSize.isNullOrBlank()) {
+ size = currSize
return@forEach
}
}
@@ -354,30 +356,10 @@ class Parser(
magnets = magnets.toList(),
torrents = torrents.toList(),
hosting = hosting.toList(),
+ isCached = false
)
}
- private fun parseCommonSize(size: String?): Double? {
- try {
-
- size ?: return null
-
- val numbers = "[\\d.]+".toRegex().find(size)?.value ?: return null
-
- val baseSize = numbers.toDouble()
- if (size.contains("gb", ignoreCase = true)) {
- return baseSize * 1024 * 1024
- }
- if (size.contains("mb", ignoreCase = true)) {
- return baseSize * 1024
- }
- // KiloBytes are already at the size I need
- return baseSize
- } catch (e: NumberFormatException) {
- return null
- }
- }
-
private fun parseTable(
tableLink: TableParser,
regexes: PluginRegexes,
@@ -489,6 +471,7 @@ class Parser(
magnets = magnets.toList(),
torrents = torrents.toList(),
hosting = hosting.toList(),
+ isCached = false
)
)
}
@@ -703,55 +686,50 @@ class Parser(
}
private fun parseDirect(
- directParser: DirectParser?,
+ parser: DirectParser,
regexes: PluginRegexes,
source: String,
url: String
): List {
- // todo: implement class and id filtering
- // todo: implement more robust method to parse multiple links for a single source
- // maybe let the class filtering separate all of the pieces like a row
val directItems = mutableListOf()
- val nameRegex = regexes.nameRegex.regexps
- val names = parseList(nameRegex, source, url)
- val magnetRegex = regexes.magnetRegex?.regexps
- val magnets = parseList(magnetRegex, source, url)
- val torrentsRegex = regexes.torrentRegexes?.regexps
- val torrents = parseList(torrentsRegex, source, url)
- val hostingRegex = regexes.hostingRegexes?.regexps
- val hosting = parseList(hostingRegex, source, url)
-
- if (names.isNotEmpty() && (magnets.isNotEmpty() || torrents.isNotEmpty() || hosting.isNotEmpty())) {
-
- val seedersRegex = regexes.seedersRegex?.regexps
- val seeders = parseList(seedersRegex, source, url)
- val leechersRegex = regexes.leechersRegex?.regexps
- val leechers = parseList(leechersRegex, source, url)
- val sizeRegex = regexes.sizeRegex?.regexps
- val size = parseList(sizeRegex, source, url)
- val detailsRegex = regexes.detailsRegex?.regexps
- val details = parseList(detailsRegex, source, url)
-
- // checking the max size of combinations between names, torrents, hosting and magnets I can find
- val maxSize: Int = minOf(names.size, maxOf(magnets.size, torrents.size, hosting.size))
- for (index in 0..maxSize) {
+ val doc: Document = Jsoup.parse(source)
+ val entries: Elements = doc.getElementsByClass(parser.entryClass)
+ for (entry in entries) {
+ // val wholeText = entry.wholeText()
+ // val data = entry.data()
+ val html = entry.html()
+
+ val name = parseSingle(regexes.nameRegex, html, url)
+ val magnets: List = parseList(regexes.magnetRegex, html, url)
+ val torrents: List = parseList(regexes.torrentRegexes, html, url)
+ val hosting: List = parseList(regexes.hostingRegexes, html, url)
+
+ if (!name.isNullOrBlank() && (magnets.isNotEmpty() || torrents.isNotEmpty() || hosting.isNotEmpty())) {
+
+ val seeders = parseSingle(regexes.seedersRegex, html, url)
+ val leechers = parseSingle(regexes.leechersRegex, html, url)
+ val size = parseSingle(regexes.sizeRegex, html, url)
+ val details = parseSingle(regexes.detailsRegex, html, url)
+
directItems.add(
ScrapedItem(
- names[index],
- details.getOrNull(index),
- seeders.getOrNull(index),
- leechers.getOrNull(index),
- size.getOrNull(index),
- parseCommonSize(size.getOrNull(index)),
- if (magnets.getOrNull(index) != null) listOf(magnets[index]) else emptyList(),
- if (torrents.getOrNull(index) != null) listOf(torrents[index]) else emptyList(),
- if (hosting.getOrNull(index) != null) listOf(hosting[index]) else emptyList()
+ name,
+ details,
+ seeders,
+ leechers,
+ size,
+ parseCommonSize(size),
+ magnets,
+ torrents,
+ hosting,
+ false
)
)
}
- return directItems
- } else return emptyList()
+ }
+
+ return directItems
}
private fun getCategory(plugin: Plugin, category: String): String? {
@@ -785,8 +763,9 @@ class Parser(
* 1.2: added table_indirect
* 2.0: use array for all regexps
* 2.1: added direct parsing mode
+ * 2.2: added entry class to direct parsing mode (required)
*/
- const val PLUGIN_ENGINE_VERSION: Float = 2.1f
+ const val PLUGIN_ENGINE_VERSION: Float = 2.2f
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt
index 5a243a2da..a36e03c78 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt
@@ -1,11 +1,13 @@
package com.github.livingwithhippos.unchained.plugins.model
-import android.os.Parcel
import android.os.Parcelable
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
+import kotlinx.parcelize.Parcelize
+// todo: replace all the Parcelable with the kotlin library
@JsonClass(generateAdapter = true)
+@Parcelize
data class Plugin(
@Json(name = "engine_version")
val engineVersion: Float,
@@ -25,47 +27,10 @@ data class Plugin(
val search: PluginSearch,
@Json(name = "download")
val download: PluginDownload
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readFloat(),
- parcel.readFloat(),
- parcel.readString()!!,
- parcel.readString()!!,
- parcel.readString(),
- parcel.readString(),
- parcel.readParcelable(SupportedCategories::class.java.classLoader)!!,
- parcel.readParcelable(PluginSearch::class.java.classLoader)!!,
- parcel.readParcelable(PluginDownload::class.java.classLoader)!!
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeFloat(engineVersion)
- parcel.writeFloat(version)
- parcel.writeString(url)
- parcel.writeString(name)
- parcel.writeString(description)
- parcel.writeString(author)
- parcel.writeParcelable(supportedCategories, flags)
- parcel.writeParcelable(search, flags)
- parcel.writeParcelable(download, flags)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): Plugin {
- return Plugin(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class SupportedCategories(
@Json(name = "all")
val all: String,
@@ -83,45 +48,10 @@ data class SupportedCategories(
val tv: String?,
@Json(name = "books")
val books: String?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString() ?: "",
- parcel.readString(),
- parcel.readString(),
- parcel.readString(),
- parcel.readString(),
- parcel.readString(),
- parcel.readString(),
- parcel.readString()
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(all)
- parcel.writeString(anime)
- parcel.writeString(software)
- parcel.writeString(games)
- parcel.writeString(movies)
- parcel.writeString(music)
- parcel.writeString(tv)
- parcel.writeString(books)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): SupportedCategories {
- return SupportedCategories(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class PluginSearch(
@Json(name = "category")
val urlCategory: String?,
@@ -129,35 +59,10 @@ data class PluginSearch(
val urlNoCategory: String,
@Json(name = "page_start")
val pageStart: Int? = 1
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString(),
- parcel.readString()!!,
- parcel.readValue(Int::class.java.classLoader) as? Int
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(urlCategory)
- parcel.writeString(urlNoCategory)
- parcel.writeValue(pageStart)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): PluginSearch {
- return PluginSearch(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class PluginDownload(
@Json(name = "internal")
val internalParser: InternalParser?,
@@ -169,71 +74,19 @@ data class PluginDownload(
val indirectTableLink: TableParser?,
@Json(name = "regexes")
val regexes: PluginRegexes
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readParcelable(InternalParser::class.java.classLoader),
- parcel.readParcelable(TableParser::class.java.classLoader),
- parcel.readParcelable(TableParser::class.java.classLoader),
- parcel.readParcelable(TableParser::class.java.classLoader),
- parcel.readParcelable(PluginRegexes::class.java.classLoader)!!
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeParcelable(internalParser, flags)
- parcel.writeParcelable(directParser, flags)
- parcel.writeParcelable(tableLink, flags)
- parcel.writeParcelable(indirectTableLink, flags)
- parcel.writeParcelable(regexes, flags)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): PluginDownload {
- return PluginDownload(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class RegexpsGroup(
@Json(name = "regex_use")
val regexUse: String = "first",
@Json(name = "regexps")
val regexps: List
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString()!!,
- parcel.createTypedArrayList(CustomRegex)!!
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(regexUse)
- parcel.writeTypedList(regexps)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): RegexpsGroup {
- return RegexpsGroup(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class CustomRegex(
@Json(name = "regex")
val regex: String,
@@ -243,93 +96,24 @@ data class CustomRegex(
val slugType: String = "complete",
@Json(name = "other")
val other: String?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString() ?: "",
- parcel.readInt(),
- parcel.readString() ?: "",
- parcel.readString()
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(regex)
- parcel.writeInt(group)
- parcel.writeString(slugType)
- parcel.writeString(other)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): CustomRegex {
- return CustomRegex(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class InternalParser(
@Json(name = "link")
val link: RegexpsGroup
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readParcelable(RegexpsGroup::class.java.classLoader)!!
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeParcelable(link, flags)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): InternalParser {
- return InternalParser(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class Internal(
@Json(name = "link")
val link: RegexpsGroup
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readParcelable(RegexpsGroup::class.java.classLoader)!!
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeParcelable(link, flags)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): Internal {
- return Internal(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class TableParser(
@Json(name = "class")
val className: String?,
@@ -337,67 +121,21 @@ data class TableParser(
val idName: String?,
@Json(name = "columns")
val columns: Columns
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString(),
- parcel.readString(),
- parcel.readParcelable(Columns::class.java.classLoader)!!
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(className)
- parcel.writeString(idName)
- parcel.writeParcelable(columns, flags)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): TableParser {
- return TableParser(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class DirectParser(
@Json(name = "class")
val className: String?,
@Json(name = "id")
- val idName: String?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString(),
- parcel.readString(),
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(className)
- parcel.writeString(idName)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): TableParser {
- return TableParser(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+ val idName: String?,
+ @Json(name = "entry-class")
+ val entryClass: String
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class PluginRegexes(
@Json(name = "name")
val nameRegex: RegexpsGroup,
@@ -415,45 +153,10 @@ data class PluginRegexes(
val hostingRegexes: RegexpsGroup?,
@Json(name = "details")
val detailsRegex: RegexpsGroup?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readParcelable(RegexpsGroup::class.java.classLoader)!!,
- parcel.readParcelable(RegexpsGroup::class.java.classLoader),
- parcel.readParcelable(RegexpsGroup::class.java.classLoader),
- parcel.readParcelable(RegexpsGroup::class.java.classLoader),
- parcel.readParcelable(RegexpsGroup::class.java.classLoader),
- parcel.readParcelable(RegexpsGroup::class.java.classLoader),
- parcel.readParcelable(RegexpsGroup::class.java.classLoader),
- parcel.readParcelable(RegexpsGroup::class.java.classLoader)
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeParcelable(nameRegex, flags)
- parcel.writeParcelable(seedersRegex, flags)
- parcel.writeParcelable(leechersRegex, flags)
- parcel.writeParcelable(sizeRegex, flags)
- parcel.writeParcelable(magnetRegex, flags)
- parcel.writeParcelable(torrentRegexes, flags)
- parcel.writeParcelable(hostingRegexes, flags)
- parcel.writeParcelable(detailsRegex, flags)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): PluginRegexes {
- return PluginRegexes(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
@JsonClass(generateAdapter = true)
+@Parcelize
data class Columns(
@Json(name = "name_column")
val nameColumn: Int?,
@@ -471,40 +174,4 @@ data class Columns(
val detailsColumn: Int?,
@Json(name = "hosting_column")
val hostingColumn: Int?
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int,
- parcel.readValue(Int::class.java.classLoader) as? Int
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeValue(nameColumn)
- parcel.writeValue(seedersColumn)
- parcel.writeValue(leechersColumn)
- parcel.writeValue(sizeColumn)
- parcel.writeValue(magnetColumn)
- parcel.writeValue(torrentColumn)
- parcel.writeValue(detailsColumn)
- parcel.writeValue(hostingColumn)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): Columns {
- return Columns(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+) : Parcelable
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/ScrapedItem.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/ScrapedItem.kt
index 6f2058c9b..003483557 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/ScrapedItem.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/ScrapedItem.kt
@@ -1,10 +1,11 @@
package com.github.livingwithhippos.unchained.plugins.model
-import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.Keep
+import kotlinx.parcelize.Parcelize
@Keep
+@Parcelize
data class ScrapedItem(
val name: String,
val link: String?,
@@ -15,42 +16,5 @@ data class ScrapedItem(
val magnets: List,
val torrents: List,
val hosting: List,
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString()!!,
- parcel.readString(),
- parcel.readString(),
- parcel.readString(),
- parcel.readString(),
- parcel.readDouble(),
- parcel.createStringArrayList() ?: emptyList(),
- parcel.createStringArrayList() ?: emptyList(),
- parcel.createStringArrayList() ?: emptyList()
- )
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(name)
- parcel.writeString(link)
- parcel.writeString(seeders)
- parcel.writeString(leechers)
- parcel.writeString(size)
- parcel.writeDouble(parsedSize ?: 0.0)
- parcel.writeStringList(magnets)
- parcel.writeStringList(torrents)
- parcel.writeStringList(hosting)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): ScrapedItem {
- return ScrapedItem(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
+ var isCached: Boolean = false,
+) : Parcelable
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt
index 2d1c6c3e9..112b707f7 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt
@@ -22,10 +22,12 @@ class LinkItemAdapter(listener: LinkItemListener) :
interface LinkItemListener {
fun onClick(item: LinkItem)
+ fun onLongClick(item: LinkItem): Boolean
}
data class LinkItem(
val type: String,
val name: String,
- val link: String
+ val link: String,
+ var cached: Boolean = false
)
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/SearchItemAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/SearchItemAdapter.kt
index 8b43bad2a..62f5a3d89 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/SearchItemAdapter.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/SearchItemAdapter.kt
@@ -14,7 +14,7 @@ class SearchItemAdapter(listener: SearchItemListener) :
oldItem.link == newItem.link
override fun areContentsTheSame(oldItem: ScrapedItem, newItem: ScrapedItem): Boolean {
- return oldItem.magnets.size == newItem.magnets.size && oldItem.torrents.size == newItem.torrents.size
+ return oldItem.magnets.size == newItem.magnets.size && oldItem.torrents.size == newItem.torrents.size && oldItem.isCached == newItem.isCached
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchFragment.kt
index b4eb3f0f2..84b2d233c 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchFragment.kt
@@ -13,12 +13,16 @@ import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.base.UnchainedFragment
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import com.github.livingwithhippos.unchained.data.repository.DownloadResult
import com.github.livingwithhippos.unchained.databinding.FragmentSearchBinding
import com.github.livingwithhippos.unchained.folderlist.view.FolderListFragment
@@ -28,6 +32,7 @@ import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem
import com.github.livingwithhippos.unchained.search.model.SearchItemAdapter
import com.github.livingwithhippos.unchained.search.model.SearchItemListener
import com.github.livingwithhippos.unchained.search.viewmodel.SearchViewModel
+import com.github.livingwithhippos.unchained.utilities.MAGNET_PATTERN
import com.github.livingwithhippos.unchained.utilities.PLUGINS_PACK_FOLDER
import com.github.livingwithhippos.unchained.utilities.PLUGINS_PACK_LINK
import com.github.livingwithhippos.unchained.utilities.PLUGINS_PACK_NAME
@@ -41,64 +46,13 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
-import java.io.IOException
@AndroidEntryPoint
class SearchFragment : UnchainedFragment(), SearchItemListener {
private val viewModel: SearchViewModel by viewModels()
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.search_bar, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.plugins_pack -> {
- lifecycleScope.launch {
- val cacheDir = context?.cacheDir
-
- if (cacheDir!=null) {
- // clean up old files
- // todo: also clear other files, at least ending with zip
- File(cacheDir, PLUGINS_PACK_FOLDER).deleteRecursively()
-
- activityViewModel.downloadFileToCache(
- PLUGINS_PACK_LINK,
- PLUGINS_PACK_NAME,
- cacheDir,
- ".zip"
- ).observe(
- viewLifecycleOwner
- ) {
- when (it) {
- is DownloadResult.End -> {
- activityViewModel.processPluginsPack(cacheDir, requireContext().filesDir, it.fileName)
- }
- DownloadResult.Failure -> {
- context?.showToast(R.string.error_loading_file)
- }
- is DownloadResult.Progress -> {
- Timber.d("Plugins pack progress: ${it.percent}")
- }
- DownloadResult.WrongURL -> {
- context?.showToast(R.string.error_loading_file)
- }
- }
- }
- }
- }
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
+ private val magnetPattern = Regex(MAGNET_PATTERN, RegexOption.IGNORE_CASE)
override fun onCreateView(
inflater: LayoutInflater,
@@ -109,6 +63,64 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
setup(binding)
+ val menuHost: MenuHost = requireActivity()
+
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.search_bar, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.plugins_pack -> {
+ context?.showToast(R.string.downloading)
+ lifecycleScope.launch {
+ val cacheDir = context?.cacheDir
+
+ if (cacheDir != null) {
+ // clean up old files
+ // todo: also clear other files, at least ending with zip
+ File(cacheDir, PLUGINS_PACK_FOLDER).deleteRecursively()
+
+ activityViewModel.downloadFileToCache(
+ PLUGINS_PACK_LINK,
+ PLUGINS_PACK_NAME,
+ cacheDir,
+ ".zip"
+ ).observe(
+ viewLifecycleOwner
+ ) {
+ when (it) {
+ is DownloadResult.End -> {
+ activityViewModel.processPluginsPack(
+ cacheDir,
+ requireContext().filesDir,
+ it.fileName
+ )
+ }
+ DownloadResult.Failure -> {
+ context?.showToast(R.string.error_loading_file)
+ }
+ is DownloadResult.Progress -> {
+ Timber.d("Plugins pack progress: ${it.percent}")
+ }
+ DownloadResult.WrongURL -> {
+ context?.showToast(R.string.error_loading_file)
+ }
+ }
+ }
+ }
+ }
+ true
+ }
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner, Lifecycle.State.RESUMED
+ )
+
return binding.root
}
@@ -197,6 +209,10 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
binding.tfSearch.setEndIconOnClickListener {
performSearch(binding, adapter)
}
+
+ activityViewModel.cacheLiveData.observe(viewLifecycleOwner) {
+ submitCachedList(it, adapter)
+ }
}
private fun showSortingPopup(
@@ -229,6 +245,9 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
R.id.sortBySizeDesc -> {
viewModel.setListSortPreference(FolderListFragment.TAG_SORT_SIZE_DESC)
}
+ R.id.sortBySeeders -> {
+ viewModel.setListSortPreference(FolderListFragment.TAG_SORT_SEEDERS)
+ }
}
// update the list and scroll it to the top
submitSortedList(searchAdapter, viewModel.getSearchResults())
@@ -253,6 +272,7 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
).observe(viewLifecycleOwner) { result ->
when (result) {
is ParserResult.SingleResult -> {
+ // does this work without an append?
submitSortedList(
searchAdapter,
listOf(result.value)
@@ -264,12 +284,15 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
searchAdapter.notifyDataSetChanged()
}
is ParserResult.SearchStarted -> {
+ searchAdapter.submitList(emptyList())
binding.sortingButton.visibility = View.INVISIBLE
binding.loadingCircle.visibility = View.VISIBLE
}
is ParserResult.SearchFinished -> {
+ activityViewModel.checkTorrentCache(searchAdapter.currentList)
binding.loadingCircle.visibility = View.INVISIBLE
binding.sortingButton.visibility = View.VISIBLE
+ // update the data with cached results
}
is ParserResult.EmptyInnerLinks -> {
context?.showToast(R.string.no_links)
@@ -278,7 +301,8 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
binding.sortingButton.visibility = View.VISIBLE
}
else -> {
- Timber.d(result.toString())
+ Timber.d("Unexpected result: $result")
+ searchAdapter.submitList(emptyList())
binding.loadingCircle.visibility = View.INVISIBLE
binding.sortingButton.visibility = View.VISIBLE
}
@@ -293,10 +317,37 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
FolderListFragment.TAG_SORT_ZA -> R.drawable.icon_sort_za
FolderListFragment.TAG_SORT_SIZE_DESC -> R.drawable.icon_sort_size_desc
FolderListFragment.TAG_SORT_SIZE_ASC -> R.drawable.icon_sort_size_asc
+ FolderListFragment.TAG_SORT_SEEDERS -> R.drawable.icon_sort_seeders
else -> R.drawable.icon_sort_default
}
}
+ private fun submitCachedList(cache: InstantAvailability, adapter: SearchItemAdapter) {
+ // alternatively get results from the viewModel
+ val items: List = adapter.currentList.map {
+ it.apply {
+ if (it.magnets.isNotEmpty()) {
+ // parse all the magnets found
+ for (magnet in it.magnets) {
+ val btih = magnetPattern.find(magnet)?.groupValues?.getOrNull(1)
+ for (torrent in cache.cachedTorrents) {
+ if (torrent.btih.equals(btih, ignoreCase = true)) {
+ isCached = true
+ break
+ }
+ }
+ // stopping at first cache hit
+ if (isCached) {
+ break
+ }
+ }
+ }
+ }
+ }
+ submitSortedList(adapter, items)
+ adapter.notifyDataSetChanged()
+ }
+
private fun submitSortedList(
adapter: SearchItemAdapter,
items: List
@@ -333,6 +384,16 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
}
)
}
+ FolderListFragment.TAG_SORT_SEEDERS -> {
+ adapter.submitList(
+ items.sortedByDescending { item ->
+ if (item.seeders != null) {
+ digitRegex.find(item.seeders)?.value?.toInt()
+ } else
+ null
+ }
+ )
+ }
else -> {
adapter.submitList(items)
}
@@ -349,7 +410,7 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
setPositiveButton(R.string.open_github) { _, _ ->
viewModel.setPluginDialogNeeded(false)
// User clicked OK button
- openExternalWebPage(PLUGINS_URL)
+ context.openExternalWebPage(PLUGINS_URL)
}
setNegativeButton(R.string.close) { _, _ ->
viewModel.setPluginDialogNeeded(false)
@@ -431,4 +492,8 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
val action = SearchFragmentDirections.actionSearchDestToSearchItemFragment(item)
findNavController().navigate(action)
}
+
+ companion object {
+ val digitRegex = "\\d+".toRegex()
+ }
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt
index 551d1907e..0b7d051a7 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt
@@ -12,7 +12,10 @@ import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem
import com.github.livingwithhippos.unchained.search.model.LinkItem
import com.github.livingwithhippos.unchained.search.model.LinkItemAdapter
import com.github.livingwithhippos.unchained.search.model.LinkItemListener
+import com.github.livingwithhippos.unchained.utilities.MAGNET_PATTERN
+import com.github.livingwithhippos.unchained.utilities.extension.copyToClipboard
import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage
+import com.github.livingwithhippos.unchained.utilities.extension.showToast
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -20,6 +23,8 @@ class SearchItemFragment : UnchainedFragment(), LinkItemListener {
private val args: SearchItemFragmentArgs by navArgs()
+ private val magnetPattern = Regex(MAGNET_PATTERN, RegexOption.IGNORE_CASE)
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -33,19 +38,28 @@ class SearchItemFragment : UnchainedFragment(), LinkItemListener {
private fun setup(binding: FragmentSearchItemBinding) {
val item: ScrapedItem = args.item
+
binding.item = item
binding.linkCaption.setOnClickListener {
if (item.link != null)
- openExternalWebPage(item.link)
+ context?.openExternalWebPage(item.link)
}
val adapter = LinkItemAdapter(this)
binding.linkList.adapter = adapter
val links = mutableListOf()
+
+ val cache: List = activityViewModel.cacheLiveData.value?.cachedTorrents?.map {
+ it.btih
+ } ?: emptyList()
item.magnets.forEach {
- links.add(LinkItem(getString(R.string.magnet), it.substringBefore("&"), it))
+ val btih = magnetPattern.find(it)?.groupValues?.getOrNull(1)?.lowercase()
+ if (btih != null && cache.contains(btih))
+ links.add(LinkItem(getString(R.string.magnet), it.substringBefore("&"), it, true))
+ else
+ links.add(LinkItem(getString(R.string.magnet), it.substringBefore("&"), it))
}
item.torrents.forEach {
links.add(LinkItem(getString(R.string.torrent), it, it))
@@ -59,4 +73,10 @@ class SearchItemFragment : UnchainedFragment(), LinkItemListener {
override fun onClick(item: LinkItem) {
activityViewModel.downloadSupportedLink(item.link)
}
+
+ override fun onLongClick(item: LinkItem): Boolean {
+ copyToClipboard(getString(R.string.link), item.link)
+ context?.showToast(R.string.link_copied)
+ return true
+ }
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt
index 9a87f0dce..dc21fd24b 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt
@@ -7,7 +7,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.github.livingwithhippos.unchained.data.local.ProtoStore
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import com.github.livingwithhippos.unchained.data.repository.PluginRepository
+import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
import com.github.livingwithhippos.unchained.folderlist.view.FolderListFragment
import com.github.livingwithhippos.unchained.folderlist.viewmodel.FolderListViewModel
import com.github.livingwithhippos.unchained.plugins.Parser
@@ -15,19 +18,12 @@ import com.github.livingwithhippos.unchained.plugins.ParserResult
import com.github.livingwithhippos.unchained.plugins.model.Plugin
import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem
import com.github.livingwithhippos.unchained.settings.view.SettingsFragment.Companion.KEY_USE_DOH
-import com.github.livingwithhippos.unchained.utilities.PLUGINS_PACK_FOLDER
-import com.github.livingwithhippos.unchained.utilities.UnzipUtils
import com.github.livingwithhippos.unchained.utilities.extension.cancelIfActive
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import java.io.File
-import java.io.IOException
import javax.inject.Inject
@HiltViewModel
@@ -35,14 +31,16 @@ class SearchViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val preferences: SharedPreferences,
private val pluginRepository: PluginRepository,
- private val parser: Parser
+ private val torrentsRepository: TorrentsRepository,
+ private val parser: Parser,
+ private val protoStore: ProtoStore
) : ViewModel() {
// used to simulate a debounce effect while typing on the search bar
private var job: Job? = null
val pluginLiveData = MutableLiveData, Int>>()
- val parsingLiveData = MutableLiveData()
+ private val parsingLiveData = MutableLiveData()
fun completeSearch(
query: String,
@@ -55,7 +53,6 @@ class SearchViewModel @Inject constructor(
if (plugin != null) {
val results = mutableListOf()
// empty saved results on new searches
- saveSearchResults(results)
job?.cancelIfActive()
job = parser.completeSearch(plugin, query, category, page)
.onEach {
@@ -63,11 +60,22 @@ class SearchViewModel @Inject constructor(
is ParserResult.SingleResult -> {
results.add(it.value)
parsingLiveData.value = ParserResult.Results(results)
- saveSearchResults(results)
+ setSearchResults(results)
}
is ParserResult.Results -> {
+ // here I have all the results at once
+ parsingLiveData.value = it
+ results.addAll(it.values)
+ setSearchResults(results)
+ }
+ is ParserResult.SearchStarted -> {
+ setCacheResults(null)
+ clearSearchResults()
+ results.clear()
+ parsingLiveData.value = it
+ }
+ is ParserResult.SearchFinished -> {
parsingLiveData.value = it
- saveSearchResults(it.values)
}
else -> parsingLiveData.value = it
}
@@ -93,8 +101,24 @@ class SearchViewModel @Inject constructor(
return savedStateHandle.get>(KEY_RESULTS) ?: emptyList()
}
- private fun saveSearchResults(results: List) {
- savedStateHandle.set(KEY_RESULTS, results)
+ private fun setSearchResults(results: List) {
+ savedStateHandle[KEY_RESULTS] = results
+ }
+
+ private fun clearSearchResults() {
+ savedStateHandle[KEY_RESULTS] = emptyList()
+ }
+
+ private fun setCacheResults(cache: InstantAvailability?) {
+ savedStateHandle[KEY_CACHE] = cache
+ }
+
+ fun getCacheResults(): InstantAvailability? {
+ return try {
+ savedStateHandle.get(KEY_CACHE)
+ } catch (e: java.lang.ClassCastException) {
+ null
+ }
}
fun getPlugins(): List {
@@ -102,7 +126,7 @@ class SearchViewModel @Inject constructor(
}
private fun setPlugins(plugins: List) {
- savedStateHandle.set(KEY_PLUGINS, plugins)
+ savedStateHandle[KEY_PLUGINS] = plugins
}
fun stopSearch() {
@@ -164,7 +188,9 @@ class SearchViewModel @Inject constructor(
}
companion object {
- const val KEY_RESULTS = "results_key"
+ // todo: these needs to be moved to a single object because if I reuse the same keys for two objects I'll get the wrong result
+ const val KEY_RESULTS = "search_results_key"
+ const val KEY_CACHE = "search_cache_key"
const val KEY_PLUGINS = "plugins_key"
const val KEY_LAST_SELECTED_PLUGIN = "plugin_last_selected_key"
const val KEY_PLUGIN_DIALOG_NEEDED = "plugin_dialog_needed_key"
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/model/KodiItemAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/model/KodiItemAdapter.kt
index 3efdaff87..44e335bef 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/model/KodiItemAdapter.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/model/KodiItemAdapter.kt
@@ -21,7 +21,6 @@ class KodiDeviceAdapter(listener: KodiDeviceListener) :
// oldItem.username == newItem.username &&
// oldItem.password == newItem.password &&
oldItem.isDefault == newItem.isDefault
-
}
override fun getItemViewType(position: Int) = R.layout.item_list_kodi_device
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiDeviceBottomSheet.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiDeviceBottomSheet.kt
index d190f58ce..18fe78bff 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiDeviceBottomSheet.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiDeviceBottomSheet.kt
@@ -146,4 +146,4 @@ class KodiDeviceBottomSheet() : BottomSheetDialogFragment() {
companion object {
const val TAG = "KodiDeviceModalBottomSheet"
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiManagementDialog.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiManagementDialog.kt
index b683c074b..dadfafc30 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiManagementDialog.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/KodiManagementDialog.kt
@@ -2,7 +2,6 @@ package com.github.livingwithhippos.unchained.settings.view
import android.app.Dialog
import android.os.Bundle
-import android.view.View
import android.widget.Button
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
@@ -12,10 +11,8 @@ import com.github.livingwithhippos.unchained.data.model.KodiDevice
import com.github.livingwithhippos.unchained.settings.model.KodiDeviceAdapter
import com.github.livingwithhippos.unchained.settings.model.KodiDeviceListener
import com.github.livingwithhippos.unchained.settings.viewmodel.KodiManagementViewModel
-import com.github.livingwithhippos.unchained.utilities.extension.showToast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
-import timber.log.Timber
@AndroidEntryPoint
class KodiManagementDialog : DialogFragment(), KodiDeviceListener {
@@ -23,7 +20,7 @@ class KodiManagementDialog : DialogFragment(), KodiDeviceListener {
private val viewModel: KodiManagementViewModel by viewModels()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- if (activity!=null) {
+ if (activity != null) {
// Use the Builder class for convenient dialog construction
val builder = MaterialAlertDialogBuilder(requireActivity())
@@ -53,7 +50,6 @@ class KodiManagementDialog : DialogFragment(), KodiDeviceListener {
// Create the AlertDialog object and return it
return builder.create()
} else throw IllegalStateException("Activity cannot be null")
-
}
private fun showNewDeviceBottomSheet() {
@@ -65,4 +61,4 @@ class KodiManagementDialog : DialogFragment(), KodiDeviceListener {
val sheet = KodiDeviceBottomSheet(item)
sheet.show(parentFragmentManager, KodiDeviceBottomSheet.TAG)
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/SettingsFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/SettingsFragment.kt
index b192cb23b..054c8204c 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/SettingsFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/SettingsFragment.kt
@@ -1,11 +1,15 @@
package com.github.livingwithhippos.unchained.settings.view
+import android.content.Intent
import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import android.text.method.DigitsKeyListener
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
@@ -20,6 +24,7 @@ import com.github.livingwithhippos.unchained.utilities.PLUGINS_URL
import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage
import com.github.livingwithhippos.unchained.utilities.extension.showToast
import dagger.hilt.android.AndroidEntryPoint
+import timber.log.Timber
import javax.inject.Inject
/**
@@ -33,6 +38,27 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val viewModel: SettingsViewModel by viewModels()
+ private val pickDirectoryLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) {
+ if (it != null) {
+ Timber.d("User has picked a folder $it")
+
+ // permanent permissions
+ val contentResolver = requireContext().contentResolver
+
+ val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+
+ contentResolver.takePersistableUriPermission(it, takeFlags)
+
+ viewModel.setDownloadFolder(it)
+ } else {
+ Timber.d("User has not picked a folder")
+ }
+ }
+
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@@ -64,6 +90,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
setupKodi()
setupVersion()
+
+ findPreference("download_folder_key")?.setOnPreferenceClickListener {
+ pickDirectoryLauncher.launch(null)
+ true
+ }
}
override fun onCreateView(
@@ -91,7 +122,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private fun setupKodi() {
findPreference("kodi_remote_control_info")?.setOnPreferenceClickListener {
- openExternalWebPage("https://kodi.wiki/view/Settings/Services/Control")
+ context?.openExternalWebPage("https://kodi.wiki/view/Settings/Services/Control")
+ ?: false
}
findPreference("kodi_list_editor")?.setOnPreferenceClickListener {
openKodiManagementDialog()
@@ -120,9 +152,16 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
+ @Suppress("DEPRECATION")
private fun setupVersion() {
-
- val pi = context?.packageManager?.getPackageInfo(requireContext().packageName, 0)
+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context?.packageManager?.getPackageInfo(
+ requireContext().packageName,
+ PackageManager.PackageInfoFlags.of(0)
+ )
+ } else {
+ context?.packageManager?.getPackageInfo(requireContext().packageName, 0)
+ }
val version = pi?.versionName
val versionPreference = findPreference("app_version")
versionPreference?.summary = version
@@ -137,8 +176,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
- "feedback" -> openExternalWebPage(FEEDBACK_URL)
- "license" -> openExternalWebPage(GPLV3_URL)
+ "feedback" -> context?.openExternalWebPage(FEEDBACK_URL)
+ "license" -> context?.openExternalWebPage(GPLV3_URL)
"credits" -> openCreditsDialog()
"terms" -> openTermsDialog()
"privacy" -> openPrivacyDialog()
@@ -146,7 +185,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel.updateRegexps()
context?.showToast(R.string.updating_link_matcher)
}
- "open_github_plugins" -> openExternalWebPage(PLUGINS_URL)
+ "open_github_plugins" -> context?.openExternalWebPage(PLUGINS_URL)
"test_kodi" -> testKodiConnection()
"delete_external_plugins" -> {
val removedPlugins = viewModel.removeExternalPlugins(requireContext())
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/KodiManagementViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/KodiManagementViewModel.kt
index ad70118c0..b51084809 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/KodiManagementViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/KodiManagementViewModel.kt
@@ -1,6 +1,5 @@
package com.github.livingwithhippos.unchained.settings.viewmodel
-import android.os.Parcel
import android.os.Parcelable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@@ -15,6 +14,7 @@ import com.github.livingwithhippos.unchained.utilities.Event
import com.github.livingwithhippos.unchained.utilities.postEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@HiltViewModel
@@ -36,7 +36,7 @@ class KodiManagementViewModel @Inject constructor(
fun updateDevice(device: KodiDevice, oldDeviceName: String) {
viewModelScope.launch {
- val inserted = deviceRepository.update(device, oldDeviceName)
+ deviceRepository.update(device, oldDeviceName)
// if the device was default and now it's not the add above will overwrite it.
// if the device was not default and now it is this will clear the old default
if (device.isDefault) {
@@ -49,7 +49,7 @@ class KodiManagementViewModel @Inject constructor(
viewModelScope.launch {
// if the device was default and now it's not the add above will overwrite it.
// if the device was not default and now it is this will clear the old default
- val inserted = deviceRepository.add(device)
+ deviceRepository.add(device)
}
}
@@ -67,15 +67,17 @@ class KodiManagementViewModel @Inject constructor(
}
fun setCurrentDevice(device: KodiDevice) {
- savedStateHandle.set(KEY_SAVED_DEVICE,
- TempKodiDevice(
- device.name,
- device.address,
- device.port,
- device.username,
- device.password,
- device.isDefault
- ))
+ savedStateHandle.set(
+ KEY_SAVED_DEVICE,
+ TempKodiDevice(
+ device.name,
+ device.address,
+ device.port,
+ device.username,
+ device.password,
+ device.isDefault
+ )
+ )
}
fun getCurrentDevice(): KodiDevice? {
@@ -98,10 +100,11 @@ class KodiManagementViewModel @Inject constructor(
}
companion object {
- const val KEY_SAVED_DEVICE="saved_item_key"
+ const val KEY_SAVED_DEVICE = "saved_item_key"
}
}
+@Parcelize
data class TempKodiDevice(
val name: String,
val address: String,
@@ -109,37 +112,4 @@ data class TempKodiDevice(
val username: String?,
val password: String?,
val isDefault: Boolean = false,
-): Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readString() ?: "kodi device",
- parcel.readString()?: "",
- parcel.readInt(),
- parcel.readString(),
- parcel.readString(),
- parcel.readByte() != 0.toByte()
- ) {
- }
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeString(name)
- parcel.writeString(address)
- parcel.writeInt(port)
- parcel.writeString(username)
- parcel.writeString(password)
- parcel.writeByte(if (isDefault) 1 else 0)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): TempKodiDevice {
- return TempKodiDevice(parcel)
- }
-
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
- }
-}
\ No newline at end of file
+) : Parcelable
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/SettingsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/SettingsViewModel.kt
index 1dd566e94..6fb646030 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/SettingsViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/viewmodel/SettingsViewModel.kt
@@ -1,12 +1,15 @@
package com.github.livingwithhippos.unchained.settings.viewmodel
import android.content.Context
+import android.content.SharedPreferences
+import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.livingwithhippos.unchained.data.repository.HostsRepository
import com.github.livingwithhippos.unchained.data.repository.KodiRepository
import com.github.livingwithhippos.unchained.data.repository.PluginRepository
+import com.github.livingwithhippos.unchained.start.viewmodel.MainActivityViewModel.Companion.KEY_DOWNLOAD_FOLDER
import com.github.livingwithhippos.unchained.utilities.Event
import com.github.livingwithhippos.unchained.utilities.postEvent
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,7 +20,8 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
private val hostsRepository: HostsRepository,
private val pluginRepository: PluginRepository,
- private val kodiRepository: KodiRepository
+ private val kodiRepository: KodiRepository,
+ private val preferences: SharedPreferences
) : ViewModel() {
val kodiLiveData = MutableLiveData>()
@@ -38,4 +42,12 @@ class SettingsViewModel @Inject constructor(
kodiLiveData.postEvent(response != null)
}
}
+
+ fun setDownloadFolder(uri: Uri) {
+ uri.describeContents()
+ with(preferences.edit()) {
+ putString(KEY_DOWNLOAD_FOLDER, uri.toString())
+ apply()
+ }
+ }
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/start/viewmodel/MainActivityViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/start/viewmodel/MainActivityViewModel.kt
index 49827cfa9..d87210093 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/start/viewmodel/MainActivityViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/start/viewmodel/MainActivityViewModel.kt
@@ -7,57 +7,81 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
+import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
+import androidx.work.Constraints
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.data.local.Credentials
import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.APIError
import com.github.livingwithhippos.unchained.data.model.ApiConversionError
+import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.EmptyBodyError
import com.github.livingwithhippos.unchained.data.model.KodiDevice
import com.github.livingwithhippos.unchained.data.model.NetworkError
+import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
import com.github.livingwithhippos.unchained.data.model.User
import com.github.livingwithhippos.unchained.data.model.UserAction
+import com.github.livingwithhippos.unchained.data.model.cache.InstantAvailability
import com.github.livingwithhippos.unchained.data.repository.AuthenticationRepository
import com.github.livingwithhippos.unchained.data.repository.CustomDownloadRepository
import com.github.livingwithhippos.unchained.data.repository.HostsRepository
import com.github.livingwithhippos.unchained.data.repository.KodiDeviceRepository
import com.github.livingwithhippos.unchained.data.repository.PluginRepository
import com.github.livingwithhippos.unchained.data.repository.PluginRepository.Companion.TYPE_UNCHAINED
+import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
+import com.github.livingwithhippos.unchained.data.repository.UpdateRepository
import com.github.livingwithhippos.unchained.data.repository.UserRepository
import com.github.livingwithhippos.unchained.data.repository.VariousApiRepository
import com.github.livingwithhippos.unchained.lists.view.ListState
-import com.github.livingwithhippos.unchained.lists.view.ListsTabFragment
+import com.github.livingwithhippos.unchained.torrentfilepicker.view.TorrentCachePickerFragment.Companion.KEY_CACHE_INDEX
import com.github.livingwithhippos.unchained.plugins.model.Plugin
+import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem
+import com.github.livingwithhippos.unchained.search.viewmodel.SearchViewModel
import com.github.livingwithhippos.unchained.statemachine.authentication.CurrentFSMAuthentication
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationEvent
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationSideEffect
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationState
+import com.github.livingwithhippos.unchained.utilities.BASE_URL
import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.Event
+import com.github.livingwithhippos.unchained.utilities.INSTANT_AVAILABILITY_ENDPOINT
+import com.github.livingwithhippos.unchained.utilities.MAGNET_PATTERN
import com.github.livingwithhippos.unchained.utilities.PLUGINS_PACK_FOLDER
import com.github.livingwithhippos.unchained.utilities.PRIVATE_TOKEN
+import com.github.livingwithhippos.unchained.utilities.PreferenceKeys
+import com.github.livingwithhippos.unchained.utilities.SIGNATURE
import com.github.livingwithhippos.unchained.utilities.UnzipUtils
+import com.github.livingwithhippos.unchained.utilities.download.DownloadWorker
import com.github.livingwithhippos.unchained.utilities.extension.getDownloadedFileUri
import com.github.livingwithhippos.unchained.utilities.extension.isMagnet
import com.github.livingwithhippos.unchained.utilities.extension.isTorrent
import com.github.livingwithhippos.unchained.utilities.postEvent
import com.tinder.StateMachine
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
+import java.io.FileNotFoundException
import java.io.IOException
import java.util.regex.Matcher
import java.util.regex.Pattern
@@ -71,16 +95,21 @@ import javax.inject.Inject
class MainActivityViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val preferences: SharedPreferences,
+ private val protoStore: ProtoStore,
private val authRepository: AuthenticationRepository,
private val userRepository: UserRepository,
private val variousApiRepository: VariousApiRepository,
private val hostsRepository: HostsRepository,
private val pluginRepository: PluginRepository,
- private val protoStore: ProtoStore,
private val kodiDeviceRepository: KodiDeviceRepository,
- private val customDownloadRepository: CustomDownloadRepository
+ private val customDownloadRepository: CustomDownloadRepository,
+ private val torrentsRepository: TorrentsRepository,
+ private val updateRepository: UpdateRepository,
+ @ApplicationContext applicationContext: Context
) : ViewModel() {
+ private val magnetPattern = Regex(MAGNET_PATTERN, RegexOption.IGNORE_CASE)
+
val fsmAuthenticationState = MutableLiveData?>()
private val credentialsFlow = protoStore.credentialsFlow
@@ -88,7 +117,7 @@ class MainActivityViewModel @Inject constructor(
val downloadedFileLiveData = MutableLiveData>()
- val notificationTorrentLiveData = MutableLiveData>()
+ val notificationTorrentLiveData = MutableLiveData>()
val listStateLiveData = MutableLiveData>()
@@ -102,6 +131,12 @@ class MainActivityViewModel @Inject constructor(
val messageLiveData = MutableLiveData?>()
+ // todo: use this with other livedatas
+ private val _cacheLiveData = MutableLiveData()
+ val cacheLiveData: LiveData = _cacheLiveData
+
+ private val workManager = WorkManager.getInstance(applicationContext)
+
private var refreshJob: Job? = null
private val authStateMachine: StateMachine =
@@ -317,7 +352,6 @@ class MainActivityViewModel @Inject constructor(
FSMAuthenticationState.StartNewLogin
)
)
-
}
FSMAuthenticationSideEffect.PostAuthenticatedOpen -> {
fsmAuthenticationState.postValue(
@@ -395,6 +429,64 @@ class MainActivityViewModel @Inject constructor(
}
}
+ fun checkTorrentCache(scrapedItems: List) {
+ if (scrapedItems.isNotEmpty()) {
+ viewModelScope.launch {
+ val builder = StringBuilder(BASE_URL)
+ builder.append(INSTANT_AVAILABILITY_ENDPOINT)
+ scrapedItems.forEach { item ->
+ item.magnets.forEach { magnet ->
+ val btih = magnetPattern.find(magnet)?.groupValues?.get(1)
+ if (!btih.isNullOrBlank()) {
+ builder.append("/")
+ builder.append(btih)
+ }
+ }
+ }
+ if (builder.length > (BASE_URL.length + INSTANT_AVAILABILITY_ENDPOINT.length)) {
+ val cache = torrentsRepository.getInstantAvailability(builder.toString())
+ if (cache is EitherResult.Success) {
+ if (cache.success.cachedTorrents.isNotEmpty()) {
+ _cacheLiveData.postValue(cache.success)
+ setCacheResults(cache.success)
+ } else {
+ setCacheResults(null)
+ }
+ } else {
+ setCacheResults(null)
+ }
+ } else {
+ Timber.d("Skipping empty cache query: $builder")
+ }
+ }
+ } else {
+ Timber.d("No search result found")
+ setCacheResults(null)
+ }
+ }
+
+ private fun setCacheResults(cache: InstantAvailability?) {
+ savedStateHandle[SearchViewModel.KEY_CACHE] = cache
+ }
+
+ /**
+ * Set current torrent cache pick, see TorrentCachePicker
+ *
+ * @param id
+ * @param cacheIndex
+ */
+ fun setCurrentTorrentCachePick(id: String, cacheIndex: Int) {
+ savedStateHandle[KEY_CACHE_INDEX] = Pair(id, cacheIndex)
+ }
+
+ fun clearCurrentTorrentCachePick() {
+ savedStateHandle[KEY_CACHE_INDEX] = null
+ }
+
+ fun getCurrentTorrentCachePick(): Pair? {
+ return savedStateHandle[KEY_CACHE_INDEX]
+ }
+
fun checkCredentials() {
viewModelScope.launch {
// todo: how to do this
@@ -453,7 +545,7 @@ class MainActivityViewModel @Inject constructor(
val c = protoStore.getCredentials()
if (!c.refreshToken.isNullOrEmpty() && c.refreshToken != PRIVATE_TOKEN) {
// setUnauthenticated()
- variousApiRepository.disableToken(c.accessToken)
+ variousApiRepository.disableToken()
}
}
}
@@ -665,7 +757,8 @@ class MainActivityViewModel @Inject constructor(
refreshJob = viewModelScope.launch {
// secondsDelay*950L -> expiration time - 5%
delay(secondsDelay * 950L)
- refreshToken()
+ if (isActive)
+ refreshToken()
}
}
@@ -732,7 +825,13 @@ class MainActivityViewModel @Inject constructor(
}
fun addTorrentId(torrentID: String) {
- notificationTorrentLiveData.postEvent(torrentID)
+ viewModelScope.launch {
+ val torrent: TorrentItem? = torrentsRepository.getTorrentInfo(torrentID)
+ if (torrent != null)
+ notificationTorrentLiveData.postEvent(torrent)
+ else
+ Timber.e("Could not retrieve torrent data from click on notification, id $torrentID")
+ }
}
fun addPlugin(context: Context, data: Uri) {
@@ -991,7 +1090,7 @@ class MainActivityViewModel @Inject constructor(
}
}
- suspend fun downloadFileToCache(
+ fun downloadFileToCache(
link: String,
fileName: String,
cacheDir: File,
@@ -1025,29 +1124,22 @@ class MainActivityViewModel @Inject constructor(
}
fun processPluginsPack(cacheDir: File, pluginsDir: File, fileName: String) {
- try {
- viewModelScope.launch {
- withContext(Dispatchers.IO) {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ try {
val cacheFile = File(cacheDir, fileName)
UnzipUtils.unzip(cacheFile, File(cacheDir, PLUGINS_PACK_FOLDER))
Timber.d("Zip pack extracted")
installPluginsPack(cacheDir, pluginsDir)
- }
- }
- } catch (exception: Exception) {
- when (exception) {
- is IOException -> {
+ } catch (exception: IOException) {
Timber.e("Plugins pack IOException error with the file: ${exception.message}")
- }
- is java.io.FileNotFoundException -> {
+ } catch (exception: FileNotFoundException) {
Timber.e("Plugins pack: file not found: ${exception.message}")
- }
- else -> {
+ } catch (exception: Exception) {
Timber.e("Plugins pack: Other error getting the file: ${exception.message}")
}
}
}
-
}
fun clearCache(cacheDir: File) {
@@ -1057,16 +1149,249 @@ class MainActivityViewModel @Inject constructor(
}
}
+ private fun checkUpdateVersion(
+ localVersion: Int,
+ remoteVersion: Int?,
+ lastVersionChecked: Int,
+ signature: String
+ ) {
+ if (remoteVersion != null) {
+ if (remoteVersion > localVersion && remoteVersion > lastVersionChecked) {
+ messageLiveData.postValue(Event(MainActivityMessage.UpdateFound(signature)))
+ }
+ with(preferences.edit()) {
+ putInt(KEY_LAST_UPDATE_VERSION_CHECKED, remoteVersion)
+ apply()
+ }
+ }
+ }
+
+ fun checkUpdates(versionCode: Int, signatures: List) {
+ viewModelScope.launch {
+ // ignore errors getting updates?
+ // todo: Add a toast if a button to check updates is added
+ val updates = updateRepository.getUpdates(SIGNATURE.URL)
+ if (updates != null) {
+ val lastVersionChecked = preferences.getInt(KEY_LAST_UPDATE_VERSION_CHECKED, -1)
+ for (signature in signatures) {
+ when (val upperSignature = signature.uppercase()) {
+ SIGNATURE.F_DROID -> {
+ checkUpdateVersion(
+ versionCode,
+ updates.fDroid?.versionCode,
+ lastVersionChecked,
+ upperSignature
+ )
+ break
+ }
+ SIGNATURE.GITHUB -> {
+ checkUpdateVersion(
+ versionCode,
+ updates.github?.versionCode,
+ lastVersionChecked,
+ upperSignature
+ )
+ break
+ }
+ SIGNATURE.PLAY_STORE -> {
+ checkUpdateVersion(
+ versionCode,
+ updates.playStore?.versionCode,
+ lastVersionChecked,
+ upperSignature
+ )
+ break
+ }
+ else -> {
+ // report to countly?
+ Timber.e("Unknown apk signature, may be debugging: $upperSignature")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun setDownloadFolder(uri: Uri) {
+ uri.describeContents()
+ with(preferences.edit()) {
+ putString(KEY_DOWNLOAD_FOLDER, uri.toString())
+ apply()
+ }
+ }
+
+ fun getDownloadFolder(): Uri? {
+ val folder = preferences.getString(KEY_DOWNLOAD_FOLDER, null)
+ if (folder != null) {
+ try {
+ return Uri.parse(folder)
+ } catch (e: Exception) {
+ Timber.e("Error parsing the saved folder Uri $folder")
+ }
+ }
+ return null
+ }
+
+ fun requireDownloadFolder() {
+ messageLiveData.postValue(Event(MainActivityMessage.RequireDownloadFolder))
+ }
+
+ fun requireDownloadPermissions() {
+ messageLiveData.postValue(Event(MainActivityMessage.RequireDownloadPermissions))
+ }
+
+ fun requireNotificationPermissions(callDelay: Boolean = true) {
+ viewModelScope.launch {
+ if (callDelay) {
+ delay(500)
+ }
+ messageLiveData.postValue(Event(MainActivityMessage.RequireNotificationPermissions))
+ }
+ }
+
+ fun getDownloadManagerPreference(): String {
+ return preferences.getString(
+ PreferenceKeys.DownloadManager.KEY,
+ PreferenceKeys.DownloadManager.SYSTEM
+ ) ?: PreferenceKeys.DownloadManager.SYSTEM
+ }
+
+ fun startDownloadWorker(content: MainActivityMessage.DownloadEnqueued, folder: Uri) {
+
+ val unmeteredConnectionOnly =
+ preferences.getBoolean(PreferenceKeys.DownloadManager.UNMETERED_ONLY_KEY, false)
+
+ val constraints = Constraints.Builder()
+ .apply {
+ if (unmeteredConnectionOnly)
+ setRequiredNetworkType(NetworkType.UNMETERED)
+ else
+ setRequiredNetworkType(NetworkType.CONNECTED)
+ }
+ .setRequiresStorageNotLow(true)
+ .build()
+
+ val data: Data = Data.Builder().apply {
+ putString(
+ KEY_FOLDER_URI,
+ folder.toString()
+ )
+ putString(
+ KEY_DOWNLOAD_SOURCE,
+ content.source
+ )
+ putString(
+ KEY_DOWNLOAD_NAME,
+ content.fileName
+ )
+ }.build()
+
+ val downloadFileRequest = OneTimeWorkRequestBuilder()
+ .addTag(content.source)
+ .setInputData(data)
+ .setConstraints(constraints)
+ .build()
+
+ // use KEEP or REPLACE
+ workManager.enqueueUniqueWork(content.source, ExistingWorkPolicy.KEEP, downloadFileRequest)
+ }
+
+ fun startMultipleDownloadWorkers(folder: Uri, downloads: List) {
+
+ val unmeteredConnectionOnly =
+ preferences.getBoolean(PreferenceKeys.DownloadManager.UNMETERED_ONLY_KEY, false)
+
+ val constraints = Constraints.Builder()
+ .apply {
+ if (unmeteredConnectionOnly)
+ setRequiredNetworkType(NetworkType.UNMETERED)
+ else
+ setRequiredNetworkType(NetworkType.CONNECTED)
+ }
+ .setRequiresStorageNotLow(true)
+ .build()
+
+ val work: List = downloads.map {
+
+ val data = Data.Builder().apply {
+ putString(
+ KEY_FOLDER_URI,
+ folder.toString()
+ )
+ putString(KEY_DOWNLOAD_SOURCE, it.download)
+ putString(KEY_DOWNLOAD_NAME, it.filename)
+ }.build()
+
+ OneTimeWorkRequestBuilder()
+ .setInputData(data)
+ .setConstraints(constraints)
+ .addTag(it.download)
+ .build()
+ }
+
+ // use KEEP or REPLACE
+ workManager.enqueue(work)
+ }
+
+ fun enqueueDownload(
+ sourceUrl: String,
+ fileName: String
+ ) {
+ // todo: folder should be nullable for the system download manager
+ messageLiveData.postValue(
+ Event(
+ MainActivityMessage.DownloadEnqueued(
+ sourceUrl,
+ fileName
+ )
+ )
+ )
+ }
+
+ fun enqueueDownloads(
+ downloads: List
+ ) {
+ // todo: folder should be nullable for the system download manager
+ messageLiveData.postValue(
+ Event(
+ MainActivityMessage.MultipleDownloadsEnqueued(
+ downloads
+ )
+ )
+ )
+ }
+
+ fun getDownloadOnUnmeteredOnlyPreference(): Boolean {
+ return preferences.getBoolean(PreferenceKeys.DownloadManager.UNMETERED_ONLY_KEY, false)
+ }
+
companion object {
+ const val KEY_DOWNLOAD_FOLDER = "download_folder_key"
const val KEY_TORRENT_DOWNLOAD_ID = "torrent_download_id_key"
const val KEY_PLUGIN_DOWNLOAD_ID = "plugin_download_id_key"
const val KEY_LAST_BACK_PRESS = "last_back_press_key"
const val KEY_REFRESHING_TOKEN = "refreshing_token_key"
const val KEY_FSM_AUTH_STATE = "fsm_auth_state_key"
+ const val KEY_LAST_UPDATE_VERSION_CHECKED = "last_update_version_checked_key"
+
+ const val KEY_FOLDER_URI = "download_folder_key"
+ const val KEY_DOWNLOAD_SOURCE = "download_source_key"
+ const val KEY_DOWNLOAD_NAME = "download_name_key"
+
+ const val DOWNLOAD_WORK_NAME = "work_name_download"
}
}
sealed class MainActivityMessage {
- data class StringID(val id: Int): MainActivityMessage()
- data class InstalledPlugins(val number: Int): MainActivityMessage()
-}
\ No newline at end of file
+ data class StringID(val id: Int) : MainActivityMessage()
+ data class InstalledPlugins(val number: Int) : MainActivityMessage()
+ data class UpdateFound(val signature: String) : MainActivityMessage()
+ object RequireDownloadFolder : MainActivityMessage()
+ object RequireDownloadPermissions : MainActivityMessage()
+ object RequireNotificationPermissions : MainActivityMessage()
+ data class DownloadEnqueued(val source: String, val fileName: String) :
+ MainActivityMessage()
+
+ data class MultipleDownloadsEnqueued(val downloads: List) :
+ MainActivityMessage()
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/model/TorrentFileStructureAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/model/TorrentFileStructureAdapter.kt
new file mode 100644
index 000000000..fe5c3a5ec
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/model/TorrentFileStructureAdapter.kt
@@ -0,0 +1,174 @@
+package com.github.livingwithhippos.unchained.torrentdetails.model
+
+import androidx.recyclerview.widget.DiffUtil
+import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.data.model.TorrentItem
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem.Companion.TYPE_FOLDER
+import com.github.livingwithhippos.unchained.utilities.DataBindingAdapter
+import com.github.livingwithhippos.unchained.utilities.DataBindingStaticAdapter
+import com.github.livingwithhippos.unchained.utilities.Node
+
+data class TorrentFileItem(
+ val id: Int,
+ val absolutePath: String,
+ val bytes: Long,
+ var selected: Boolean,
+ val name: String
+) {
+ override fun equals(other: Any?): Boolean {
+ return if (other is TorrentFileItem)
+ this.id == other.id && this.absolutePath == other.absolutePath && this.name == other.name
+ else
+ super.equals(other)
+ }
+
+ override fun hashCode(): Int {
+ var result = id
+ result = 31 * result + absolutePath.hashCode()
+ result = 31 * result + name.hashCode()
+ return result
+ }
+
+ companion object {
+ const val TYPE_FOLDER = -1
+ }
+}
+
+fun getFilesNodes(
+ item: TorrentItem,
+ selectedOnly: Boolean = false,
+ flattenFolders: Boolean = false
+): Node {
+ val rootFolder = Node(
+ TorrentFileItem(
+ TYPE_FOLDER,
+ "",
+ 0,
+ selected = false,
+ "/"
+ )
+ )
+
+ if (item.files != null && item.files.isNotEmpty()) {
+ val files = if (selectedOnly) item.files.filter { it.selected == 1 } else item.files
+ for (file in files) {
+ val paths = file.path.split("/").drop(1)
+ // todo: just use InnerTorrentFile instead of TorrentFileItem
+ var currentNode = rootFolder
+ paths.forEachIndexed { index, value ->
+ if (index == paths.lastIndex) {
+ // this is a file, end of path
+ currentNode.children.add(
+ Node(
+ TorrentFileItem(
+ file.id,
+ paths.dropLast(1).joinToString("/"),
+ file.bytes,
+ selected = file.selected == 1,
+ value
+ )
+ )
+ )
+ } else {
+ // this is a folder
+ val node = currentNode.children.firstOrNull { it.value.name == value }
+ if (node == null) {
+ currentNode.children.add(
+ Node(
+ TorrentFileItem(
+ TYPE_FOLDER,
+ paths.subList(0, index + 1).joinToString("/"),
+ 0,
+ selected = false,
+ value
+ )
+ )
+ )
+ }
+
+ currentNode = currentNode.children.first { it.value.name == value }
+ }
+ }
+ }
+ }
+
+ if (flattenFolders) {
+ val newRootFolder = Node(
+ TorrentFileItem(
+ TYPE_FOLDER,
+ "",
+ 0,
+ selected = false,
+ "/"
+ )
+ )
+ }
+
+ return rootFolder
+}
+
+interface TorrentContentListener {
+ fun onSelectedFile(item: TorrentFileItem)
+ fun onSelectedFolder(item: TorrentFileItem)
+}
+
+class TorrentContentFilesAdapter :
+ DataBindingStaticAdapter(DiffCallback()) {
+
+ class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: TorrentFileItem,
+ newItem: TorrentFileItem
+ ): Boolean {
+ return oldItem.absolutePath == newItem.absolutePath &&
+ oldItem.name == newItem.name &&
+ oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: TorrentFileItem,
+ newItem: TorrentFileItem
+ ): Boolean {
+ // content is not dynamic unless selected is added
+ return true
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ val item: TorrentFileItem = this.getItem(position)
+ return if (item.id == TYPE_FOLDER)
+ R.layout.item_list_torrent_directory
+ else
+ R.layout.item_list_torrent_file
+ }
+}
+
+class TorrentContentFilesSelectionAdapter(listener: TorrentContentListener) :
+ DataBindingAdapter(DiffCallback(), listener) {
+
+ class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: TorrentFileItem,
+ newItem: TorrentFileItem
+ ): Boolean {
+ return oldItem.id == newItem.id &&
+ oldItem.name == newItem.name &&
+ oldItem.absolutePath == newItem.absolutePath
+ }
+
+ override fun areContentsTheSame(
+ oldItem: TorrentFileItem,
+ newItem: TorrentFileItem
+ ): Boolean {
+ return oldItem.selected == newItem.selected
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ val item: TorrentFileItem = this.getItem(position)
+ return if (item.id == TYPE_FOLDER)
+ R.layout.item_list_torrent_selection_directory
+ else
+ R.layout.item_list_torrent_selection_file
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/view/TorrentDetailsFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/view/TorrentDetailsFragment.kt
index aad6b2d56..b883243b5 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/view/TorrentDetailsFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/view/TorrentDetailsFragment.kt
@@ -7,10 +7,12 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
-import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.Lifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.github.livingwithhippos.unchained.R
@@ -23,15 +25,17 @@ import com.github.livingwithhippos.unchained.data.model.NetworkError
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.databinding.FragmentTorrentDetailsBinding
import com.github.livingwithhippos.unchained.lists.view.ListState
-import com.github.livingwithhippos.unchained.lists.view.ListsTabFragment
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentContentFilesAdapter
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentContentListener
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem
+import com.github.livingwithhippos.unchained.torrentdetails.model.getFilesNodes
import com.github.livingwithhippos.unchained.torrentdetails.viewmodel.TorrentDetailsViewModel
import com.github.livingwithhippos.unchained.utilities.EventObserver
+import com.github.livingwithhippos.unchained.utilities.Node
import com.github.livingwithhippos.unchained.utilities.extension.getApiErrorMessage
import com.github.livingwithhippos.unchained.utilities.extension.showToast
import com.github.livingwithhippos.unchained.utilities.loadingStatusList
import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
/**
* A simple [Fragment] subclass.
@@ -44,27 +48,6 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
private val args: TorrentDetailsFragmentArgs by navArgs()
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.torrent_details_bar, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.delete -> {
- val dialog = DeleteDialogFragment()
- dialog.show(parentFragmentManager, "DeleteDialogFragment")
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -72,6 +55,38 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
): View {
val torrentBinding = FragmentTorrentDetailsBinding.inflate(inflater, container, false)
+ val menuHost: MenuHost = requireActivity()
+
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ // Add menu items here
+ menuInflater.inflate(R.menu.torrent_details_bar, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.delete -> {
+ val dialog = DeleteDialogFragment()
+ dialog.show(parentFragmentManager, "DeleteDialogFragment")
+ true
+ }
+ R.id.reselect -> {
+ val link = "magnet:?xt=urn:btih:${args.item.hash}"
+ val action =
+ TorrentDetailsFragmentDirections.actionTorrentDetailsDestToTorrentProcessingFragment(
+ link = link
+ )
+ findNavController().navigate(action)
+ true
+ }
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner, Lifecycle.State.RESUMED
+ )
+
val statusTranslation = mapOf(
"magnet_error" to getString(R.string.magnet_error),
"magnet_conversion" to getString(R.string.magnet_conversion),
@@ -90,22 +105,38 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
torrentBinding.statusTranslation = statusTranslation
torrentBinding.listener = this
- var firstTorrentStatus: String? = null
+ val adapter = TorrentContentFilesAdapter()
+ torrentBinding.rvFileList.adapter = adapter
viewModel.torrentLiveData.observe(
viewLifecycleOwner,
EventObserver {
it?.let { torrent ->
torrentBinding.torrent = torrent
- if (loadingStatusList.contains(torrent.status))
- fetchTorrent()
- // save the last retrieved status
- if (firstTorrentStatus == null)
- firstTorrentStatus = torrent.status
- // if the torrent wasn't initially in a downloaded status and reached the downloaded status un-restrict it
- // possibly let the user enable this from settings
- if (torrent.status == "downloaded" && firstTorrentStatus != "downloaded")
- torrentBinding.bDownload.performClick()
+ val selectedFiles: Int =
+ torrent.files?.count { file -> file.selected == 1 } ?: 0
+ torrentBinding.tvSelectedFilesNumber.text = selectedFiles.toString()
+ torrentBinding.tvTotalFiles.text = (torrent.files?.count() ?: 0).toString()
+
+ // Data should not change between updates so we should just populate it once
+ if (adapter.itemCount == 0) {
+ val torrentStructure: Node =
+ getFilesNodes(torrent, selectedOnly = true)
+ // show list only if it's populated enough
+ if (torrentStructure.children.size > 0) {
+ val filesList = mutableListOf()
+ var skippedFirst = false
+ Node.traverseDepthFirst(torrentStructure) { item ->
+ // avoid root item "/"
+ if (!skippedFirst)
+ skippedFirst = true
+ else
+ filesList.add(item)
+ }
+ adapter.submitList(filesList)
+ torrentBinding.cvSelectedTorrentFiles.visibility = View.VISIBLE
+ }
+ }
}
}
)
@@ -123,7 +154,7 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
setFragmentResultListener("deleteActionKey") { _, bundle ->
if (bundle.getBoolean("deleteConfirmation"))
- viewModel.deleteTorrent(args.torrentID)
+ viewModel.deleteTorrent(args.item.id)
}
viewModel.downloadLiveData.observe(
@@ -162,17 +193,16 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
}
)
- viewModel.fetchTorrentDetails(args.torrentID)
-
- return torrentBinding.root
- }
+ torrentBinding.torrent = args.item
- // fetch the torrent info every 2 seconds
- private fun fetchTorrent(delay: Long = 1000) {
- lifecycleScope.launch {
- delay(delay)
- viewModel.fetchTorrentDetails(args.torrentID)
+ // maybe load and save the latest retrieved one in the view-model?
+ if (loadingStatusList.contains(args.item.status))
+ viewModel.pollTorrentStatus(args.item.id)
+ else {
+ viewModel.getFullTorrentInfo(args.item.id)
}
+
+ return torrentBinding.root
}
override fun onDownloadClick(item: TorrentItem) {
@@ -184,7 +214,7 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
)
findNavController().navigate(action)
} else {
- viewModel.downloadTorrent()
+ viewModel.downloadTorrent(item)
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt
index f3e563d80..499652dfb 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt
@@ -3,7 +3,6 @@ package com.github.livingwithhippos.unchained.torrentdetails.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.livingwithhippos.unchained.data.local.ProtoStore
import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.TorrentItem
import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
@@ -11,8 +10,15 @@ import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
import com.github.livingwithhippos.unchained.data.repository.UnrestrictRepository
import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.Event
+import com.github.livingwithhippos.unchained.utilities.endedStatusList
+import com.github.livingwithhippos.unchained.utilities.extension.cancelIfActive
import com.github.livingwithhippos.unchained.utilities.postEvent
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -23,7 +29,6 @@ import javax.inject.Inject
@HiltViewModel
class TorrentDetailsViewModel @Inject constructor(
private val torrentsRepository: TorrentsRepository,
- private val protoStore: ProtoStore,
private val unrestrictRepository: UnrestrictRepository
) : ViewModel() {
@@ -32,30 +37,44 @@ class TorrentDetailsViewModel @Inject constructor(
val downloadLiveData = MutableLiveData>()
val errorsLiveData = MutableLiveData>>()
- fun fetchTorrentDetails(torrentID: String) {
+ private var job = Job()
+
+ fun getFullTorrentInfo(id: String) {
viewModelScope.launch {
- val token = getToken()
- val torrentData =
- torrentsRepository.getTorrentInfo(token, torrentID)
- torrentLiveData.postValue(Event(torrentData))
- if (torrentData?.status == "waiting_files_selection")
- torrentsRepository.selectFiles(token, torrentID)
+ val torrentData = torrentsRepository.getTorrentInfo(id)
+ if (torrentData != null)
+ torrentLiveData.postEvent(torrentData)
}
}
- private suspend fun getToken(): String {
- val token = protoStore.getCredentials().accessToken
- if (token.isBlank() || token.length < 5)
- throw IllegalArgumentException("Loaded token was empty or wrong: $token")
+ fun pollTorrentStatus(id: String) {
+ // todo: test if I need to recreate a job when it is cancelled
+ job.cancelIfActive()
+ job = Job()
+
+ val scope = CoroutineScope(job + Dispatchers.IO)
+
+ scope.launch {
+ /// maybe job.isActive?
+ while (isActive) {
+ val torrentData = torrentsRepository.getTorrentInfo(id)
+ if (torrentData != null)
+ torrentLiveData.postEvent(torrentData)
+ if (endedStatusList.contains(torrentData?.status))
+ job.cancelIfActive()
+
+ delay(2000)
+ }
+ }
+ }
- return token
+ fun stopPolling() {
+ job.cancelIfActive()
}
fun deleteTorrent(id: String) {
viewModelScope.launch {
- val token = getToken()
- val deleted = torrentsRepository.deleteTorrent(token, id)
- when (deleted) {
+ when (val deleted = torrentsRepository.deleteTorrent(id)) {
is EitherResult.Failure -> {
errorsLiveData.postEvent(listOf(deleted.failure))
}
@@ -66,26 +85,23 @@ class TorrentDetailsViewModel @Inject constructor(
}
}
- fun downloadTorrent() {
+ fun downloadTorrent(torrent: TorrentItem) {
viewModelScope.launch {
- val token = protoStore.getCredentials().accessToken
- torrentLiveData.value?.let { torrent ->
- val links = torrent.peekContent()?.links
- if (links != null) {
- val items = unrestrictRepository.getUnrestrictedLinkList(token, links)
+ val links = torrent.links
+ if (links.isNotEmpty()) {
+ val items = unrestrictRepository.getUnrestrictedLinkList(links)
- val values =
- items.filterIsInstance>()
- .map { it.success }
- val errors =
- items.filterIsInstance>()
- .map { it.failure }
+ val values =
+ items.filterIsInstance>()
+ .map { it.success }
+ val errors =
+ items.filterIsInstance>()
+ .map { it.failure }
- // since the torrent want to open a download details page we oen only the first link
- downloadLiveData.postEvent(values.firstOrNull())
- if (errors.isNotEmpty())
- errorsLiveData.postEvent(errors)
- }
+ // since the torrent want to open a download details page we oen only the first link
+ downloadLiveData.postEvent(values.firstOrNull())
+ if (errors.isNotEmpty())
+ errorsLiveData.postEvent(errors)
}
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/model/CacheFileAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/model/CacheFileAdapter.kt
new file mode 100644
index 000000000..021a12232
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/model/CacheFileAdapter.kt
@@ -0,0 +1,22 @@
+package com.github.livingwithhippos.unchained.torrentfilepicker.model
+
+import androidx.recyclerview.widget.DiffUtil
+import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.data.model.cache.CachedFile
+import com.github.livingwithhippos.unchained.utilities.DataBindingStaticAdapter
+
+class CacheFileAdapter :
+ DataBindingStaticAdapter(
+ DiffCallback()
+ ) {
+ class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: CachedFile, newItem: CachedFile): Boolean =
+ oldItem.id == newItem.id
+
+ override fun areContentsTheSame(oldItem: CachedFile, newItem: CachedFile): Boolean {
+ return oldItem.fileName == newItem.fileName && oldItem.fileSize == newItem.fileSize
+ }
+ }
+
+ override fun getItemViewType(position: Int) = R.layout.item_cache_file
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentCacheListFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentCacheListFragment.kt
new file mode 100644
index 000000000..cf909ade4
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentCacheListFragment.kt
@@ -0,0 +1,67 @@
+package com.github.livingwithhippos.unchained.torrentfilepicker.view
+
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import com.github.livingwithhippos.unchained.data.model.cache.CachedAlternative
+import com.github.livingwithhippos.unchained.databinding.FragmentCacheBinding
+import com.github.livingwithhippos.unchained.torrentfilepicker.model.CacheFileAdapter
+import com.github.livingwithhippos.unchained.utilities.extension.setFileSize
+
+
+class TorrentCacheListFragment() : Fragment() {
+
+ private var cache: CachedAlternative? = null
+
+ @Suppress("DEPRECATION")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ arguments?.let {
+ // it.classLoader = ?
+ cache = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ it.getParcelable(CACHE_KEY, CachedAlternative::class.java)
+ else
+ it.getParcelable(CACHE_KEY)
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = FragmentCacheBinding.inflate(inflater, container, false)
+
+ val listsAdapter = CacheFileAdapter()
+ binding.rvCacheList.adapter = listsAdapter
+
+ cache?.let { currCache ->
+ listsAdapter.submitList(currCache.cachedFiles.sortedByDescending { it.fileSize })
+
+ val totalSize: Long = currCache.cachedFiles.sumOf { it.fileSize }
+ val totalFiles = currCache.cachedFiles.size.toString()
+
+ binding.tvFilesNumber.text = totalFiles
+ binding.tvTotalSize.setFileSize(totalSize)
+ }
+
+
+ return binding.root
+ }
+
+ companion object {
+
+ private const val CACHE_KEY = "key_cache"
+
+ @JvmStatic
+ fun newInstance(cache: CachedAlternative) =
+ TorrentCacheListFragment().apply {
+ arguments = Bundle().apply {
+ putParcelable(CACHE_KEY, cache)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentCachePickerFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentCachePickerFragment.kt
new file mode 100644
index 000000000..23e22d184
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentCachePickerFragment.kt
@@ -0,0 +1,99 @@
+package com.github.livingwithhippos.unchained.torrentfilepicker.view
+
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.widget.ViewPager2
+import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.base.UnchainedFragment
+import com.github.livingwithhippos.unchained.data.model.cache.CachedTorrent
+import com.github.livingwithhippos.unchained.databinding.FragmentTorrentCachePickerBinding
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+
+private const val CACHE_LIST_KEY = "key_cache_list"
+
+class TorrentCachePickerFragment : UnchainedFragment() {
+
+ private lateinit var cache: CachedTorrent
+ private lateinit var torrentID: String
+
+ @Suppress("DEPRECATION")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ arguments?.let {
+ // it.classLoader = ?
+ cache = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ it.getParcelable(CACHE_LIST_KEY, CachedTorrent::class.java)!!
+ } else {
+ it.getParcelable(CACHE_LIST_KEY)!!
+ }
+ torrentID = it.getString(KEY_TORRENT_ID) ?: ""
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = FragmentTorrentCachePickerBinding.inflate(inflater, container, false)
+
+ val cacheAdapter =
+ CachePagerAdapter(this, cache)
+ binding.cachePager.adapter = cacheAdapter
+ binding.cacheTabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab?) {
+ activityViewModel.setCurrentTorrentCachePick(torrentID, tab?.position ?: 0)
+ }
+
+ override fun onTabUnselected(tab: TabLayout.Tab?) {
+ }
+
+ override fun onTabReselected(tab: TabLayout.Tab?) {
+ }
+ })
+
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val tabLayout: TabLayout = view.findViewById(R.id.cacheTabs)
+ val viewPager: ViewPager2 = view.findViewById(R.id.cachePager)
+ TabLayoutMediator(tabLayout, viewPager) { tab, position ->
+ tab.text = getString(R.string.cache_format, position + 1)
+ }.attach()
+
+ super.onViewCreated(view, savedInstanceState)
+ }
+
+ companion object {
+ const val KEY_CACHE_INDEX = "cache_index_key"
+ const val KEY_TORRENT_ID = "torrent_id_key"
+
+ @JvmStatic
+ fun newInstance(cache: CachedTorrent?, torrentID: String) =
+ TorrentCachePickerFragment().apply {
+ if (cache != null)
+ arguments = Bundle().apply {
+ putParcelable(CACHE_LIST_KEY, cache)
+ putString(KEY_TORRENT_ID, torrentID)
+ }
+ }
+ }
+}
+
+
+class CachePagerAdapter(fragment: Fragment, private val cache: CachedTorrent) :
+ FragmentStateAdapter(fragment) {
+
+ override fun getItemCount(): Int = cache.cachedAlternatives.size
+
+ override fun createFragment(position: Int): Fragment {
+ return TorrentCacheListFragment.newInstance(cache.cachedAlternatives[position])
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentFilePickerFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentFilePickerFragment.kt
new file mode 100644
index 000000000..c7c022031
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentFilePickerFragment.kt
@@ -0,0 +1,120 @@
+package com.github.livingwithhippos.unchained.torrentfilepicker.view
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.hilt.navigation.fragment.hiltNavGraphViewModels
+import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.databinding.FragmentTorrentFilePickerBinding
+import com.github.livingwithhippos.unchained.torrentfilepicker.viewmodel.TorrentEvent
+import com.github.livingwithhippos.unchained.torrentfilepicker.viewmodel.TorrentProcessingViewModel
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentContentFilesSelectionAdapter
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentContentListener
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem.Companion.TYPE_FOLDER
+import com.github.livingwithhippos.unchained.torrentdetails.model.getFilesNodes
+import com.github.livingwithhippos.unchained.utilities.Node
+import timber.log.Timber
+
+private const val ARG_TORRENT = "torrent_arg"
+
+class TorrentFilePickerFragment : Fragment(), TorrentContentListener {
+
+ // https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodel-navigation
+ private val viewModel: TorrentProcessingViewModel by hiltNavGraphViewModels(R.id.navigation_lists)
+
+ private var currentStructure: Node? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = FragmentTorrentFilePickerBinding.inflate(inflater, container, false)
+
+ val adapter = TorrentContentFilesSelectionAdapter(this)
+ binding.rvTorrentFilePicker.adapter = adapter
+
+ viewModel.torrentLiveData.observe(viewLifecycleOwner) {
+ when (val content = it.peekContent()) {
+ is TorrentEvent.TorrentInfo -> {
+
+ if (currentStructure == null) {
+ val torrentStructure: Node =
+ getFilesNodes(content.item, selectedOnly = false)
+ if (torrentStructure.children.size > 0) {
+ currentStructure = torrentStructure
+ viewModel.updateTorrentStructure(torrentStructure)
+ }
+ }
+ }
+ else -> {
+ // not used by this fragment
+ }
+ }
+ }
+
+ viewModel.structureLiveData.observe(viewLifecycleOwner) {
+ val content = it.getContentIfNotHandled()
+ if (content != null) {
+ val filesList = mutableListOf()
+ Node.traverseDepthFirst(content) { item ->
+ filesList.add(item)
+ }
+ adapter.submitList(filesList)
+ adapter.notifyDataSetChanged()
+ }
+ }
+
+ return binding.root
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance() = TorrentFilePickerFragment()
+ }
+
+ override fun onSelectedFile(item: TorrentFileItem) {
+ Timber.d("selected file $item was ${item.selected}")
+ currentStructure?.let { structure ->
+ Node.traverseDepthFirst(structure) {
+ if (it.id == item.id) {
+ it.selected = !it.selected
+ }
+ }
+ }
+ viewModel.updateTorrentStructure(currentStructure)
+ }
+
+ override fun onSelectedFolder(item: TorrentFileItem) {
+ Timber.d("selected folder $item")
+ currentStructure?.let { structure ->
+
+ var folderNode: Node? = null
+ Node.traverseNodeDepthFirst(structure) {
+ if (
+ it.value.absolutePath == item.absolutePath &&
+ it.value.name == item.name &&
+ it.value.id == TYPE_FOLDER &&
+ item.id == TYPE_FOLDER
+ ) {
+ folderNode = it
+ return@traverseNodeDepthFirst
+ }
+ }
+
+ folderNode?.let {
+ val newSelected = !it.value.selected
+ it.value.selected = newSelected
+ Node.traverseDepthFirst(it) { item ->
+ Timber.d("Set ${item.name} as $newSelected")
+ item.selected = newSelected
+ }
+ }
+
+ }
+
+ viewModel.updateTorrentStructure(currentStructure)
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt
new file mode 100644
index 000000000..18f30ed54
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt
@@ -0,0 +1,417 @@
+package com.github.livingwithhippos.unchained.torrentfilepicker.view
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.PopupMenu
+import androidx.annotation.MenuRes
+import androidx.fragment.app.Fragment
+import androidx.hilt.navigation.fragment.hiltNavGraphViewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.widget.ViewPager2
+import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.base.UnchainedFragment
+import com.github.livingwithhippos.unchained.data.model.cache.CachedAlternative
+import com.github.livingwithhippos.unchained.data.model.cache.CachedTorrent
+import com.github.livingwithhippos.unchained.data.repository.DownloadResult
+import com.github.livingwithhippos.unchained.databinding.FragmentTorrentProcessingBinding
+import com.github.livingwithhippos.unchained.lists.view.ListState
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem.Companion.TYPE_FOLDER
+import com.github.livingwithhippos.unchained.torrentfilepicker.view.TorrentProcessingFragment.Companion.POSITION_FILE_PICKER
+import com.github.livingwithhippos.unchained.torrentfilepicker.viewmodel.TorrentEvent
+import com.github.livingwithhippos.unchained.torrentfilepicker.viewmodel.TorrentProcessingViewModel
+import com.github.livingwithhippos.unchained.utilities.Node
+import com.github.livingwithhippos.unchained.utilities.beforeSelectionStatusList
+import com.github.livingwithhippos.unchained.utilities.extension.isMagnet
+import com.github.livingwithhippos.unchained.utilities.extension.isTorrent
+import com.github.livingwithhippos.unchained.utilities.extension.showToast
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+import dagger.hilt.android.AndroidEntryPoint
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+/**
+ * This fragments is shown after a user uploads a torrent or a magnet.
+ */
+@AndroidEntryPoint
+class TorrentProcessingFragment : UnchainedFragment() {
+
+ private val args: TorrentProcessingFragmentArgs by navArgs()
+
+ // https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodel-navigation
+ private val viewModel: TorrentProcessingViewModel by hiltNavGraphViewModels(R.id.navigation_lists)
+
+ private var cachedTorrent: CachedTorrent? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = FragmentTorrentProcessingBinding.inflate(inflater, container, false)
+
+ setup(binding)
+
+ viewModel.torrentLiveData.observe(viewLifecycleOwner) {
+ when (val content = it.getContentIfNotHandled()) {
+ is TorrentEvent.Uploaded -> {
+ binding.tvStatus.text = getString(R.string.loading_torrent)
+ // get torrent info
+ viewModel.fetchTorrentDetails(content.torrent.id)
+ // todo: add a loop so this is repeated if it fails, instead of wasting the fragment
+ }
+ is TorrentEvent.TorrentInfo -> {
+ // torrent loaded
+ if (activityViewModel.getCurrentTorrentCachePick()?.first != content.item.id) {
+ // clear up old cache
+ activityViewModel.clearCurrentTorrentCachePick()
+ }
+ // check if we are already beyond file selection
+ if (!beforeSelectionStatusList.contains(content.item.status)) {
+ val action =
+ TorrentProcessingFragmentDirections.actionTorrentProcessingFragmentToTorrentDetailsDest(
+ item = content.item
+ )
+ findNavController().navigate(action)
+ } else {
+ binding.tvStatus.text = getString(R.string.checking_cache)
+ val hash = content.item.hash.lowercase()
+ // Check if the activity already has the torrent cache, otherwise check it again
+ val currentCache: CachedTorrent? =
+ activityViewModel.cacheLiveData.value?.cachedTorrents?.firstOrNull { tor ->
+ tor.btih.equals(
+ hash,
+ ignoreCase = true
+ )
+ }
+ if (
+ currentCache != null
+ ) {
+ // trigger cache hit without checking it
+ viewModel.triggerCacheResult(currentCache)
+ } else {
+ // check this torrent cache again
+ viewModel.checkTorrentCache(hash)
+ }
+ }
+ }
+ is TorrentEvent.CacheHit -> {
+ Timber.d("Found cache ${content.cache}")
+
+ binding.loadingLayout.visibility = View.INVISIBLE
+ binding.loadedLayout.visibility = View.VISIBLE
+
+ cachedTorrent = content.cache
+
+ if (content.cache.cachedAlternatives.isNotEmpty()) {
+ // cache found, enable tab swiping and clicking
+ binding.pickerPager.isUserInputEnabled = true
+
+ binding.pickerTabs.getTabAt(0)?.view?.isClickable = true
+ binding.pickerTabs.getTabAt(1)?.view?.isClickable = true
+ } else {
+ context?.showToast(R.string.cache_missing)
+ }
+ }
+ TorrentEvent.CacheMiss -> {
+ // fixme: here or above here got triggered but the views were still swipable
+ context?.showToast(R.string.cache_missing)
+
+ binding.loadingLayout.visibility = View.INVISIBLE
+ binding.loadedLayout.visibility = View.VISIBLE
+
+ Timber.d("Cached torrent not found")
+ }
+ is TorrentEvent.FilesSelected -> {
+ activityViewModel.setListState(ListState.UpdateTorrent)
+ val action =
+ TorrentProcessingFragmentDirections.actionTorrentProcessingFragmentToTorrentDetailsDest(
+ item = content.torrent
+ )
+ findNavController().navigate(action)
+ }
+ TorrentEvent.DownloadAll -> {
+ binding.tvStatus.text = getString(R.string.selecting_all_files)
+ binding.tvLoadingTorrent.visibility = View.INVISIBLE
+ binding.loadingLayout.visibility = View.VISIBLE
+ binding.loadedLayout.visibility = View.INVISIBLE
+ }
+ is TorrentEvent.DownloadCache -> {
+ binding.tvStatus.text =
+ getString(R.string.selecting_picked_cache, content.files, content.position)
+ binding.tvLoadingTorrent.visibility = View.INVISIBLE
+ binding.loadingLayout.visibility = View.VISIBLE
+ binding.loadedLayout.visibility = View.INVISIBLE
+ }
+ is TorrentEvent.DownloadSelection -> {
+ binding.tvStatus.text = getString(R.string.selecting_picked_files, content.filesNumber)
+ binding.tvLoadingTorrent.visibility = View.INVISIBLE
+ binding.loadingLayout.visibility = View.VISIBLE
+ binding.loadedLayout.visibility = View.INVISIBLE
+ }
+ TorrentEvent.DownloadedFileFailure -> {
+ binding.tvStatus.text = getString(R.string.error_loading_torrent)
+ binding.tvLoadingTorrent.visibility = View.INVISIBLE
+ binding.loadingCircle.isIndeterminate = false
+ binding.loadingCircle.progress = 100
+ binding.loadingLayout.visibility = View.VISIBLE
+ binding.loadedLayout.visibility = View.INVISIBLE
+
+ }
+ is TorrentEvent.DownloadedFileProgress -> {
+ binding.tvStatus.text = getString(R.string.downloading_torrent)
+ binding.loadingCircle.isIndeterminate = false
+ binding.loadingCircle.progress = content.progress
+
+ }
+ TorrentEvent.DownloadedFileSuccess -> {
+ // do nothing
+ }
+ else -> {
+ Timber.d("Found unknown torrentLiveData event $content")
+ // reloaded fragment, close?
+ }
+ }
+ }
+
+ return binding.root
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ activityViewModel.clearCurrentTorrentCachePick()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val tabLayout: TabLayout = view.findViewById(R.id.pickerTabs)
+ val viewPager: ViewPager2 = view.findViewById(R.id.pickerPager)
+ TabLayoutMediator(tabLayout, viewPager) { tab, position ->
+ /**
+ * disable clicking until the data and cache are loaded
+ */
+ tab.view.isClickable = false
+
+ when (position) {
+ POSITION_FILE_PICKER -> {
+ tab.text = getString(R.string.select_files)
+ }
+ POSITION_CACHE_PICKER -> {
+ tab.text = getString(R.string.pick_cache)
+ }
+ }
+ }.attach()
+
+
+ super.onViewCreated(view, savedInstanceState)
+ }
+
+ private fun setup(binding: FragmentTorrentProcessingBinding) {
+
+ // disable swiping until the data and cache are loaded
+ binding.pickerPager.isUserInputEnabled = false
+
+ binding.fabDownload.setOnClickListener {
+ showMenu(it, R.menu.download_mode_picker)
+ }
+
+ if (args.torrentID != null) {
+ // we are loading an already available torrent
+ args.torrentID?.let {
+ viewModel.fetchTorrentDetails(it)
+ }
+ } else if (args.link != null) {
+ // we are loading a new torrent
+ args.link?.let {
+ when {
+ it.isTorrent() -> {
+ Timber.d("Found torrent $it")
+ downloadTorrentToCache(it)
+ }
+ args.link.isMagnet() -> {
+ Timber.d("Found magnet $it")
+ viewModel.fetchAddedMagnet(it)
+ }
+ else -> {
+ Timber.e("Torrent processing link not recognized: $it")
+ }
+ }
+ }
+ } else {
+ throw IllegalArgumentException("No torrent link or torrent id was passed to TorrentProcessingFragment")
+ }
+
+
+ val adapter = TorrentFilePagerAdapter(this, viewModel)
+ binding.pickerPager.adapter = adapter
+ }
+
+ private fun showMenu(v: View, @MenuRes menuRes: Int) {
+ val popup = PopupMenu(requireContext(), v)
+ popup.menuInflater.inflate(menuRes, popup.menu)
+
+ val pick = activityViewModel.getCurrentTorrentCachePick()
+ if (pick == null)
+ popup.menu.findItem(R.id.download_cache).isEnabled = false
+
+ val lastSelection: Node? = viewModel.structureLiveData.value?.peekContent()
+ if (lastSelection == null)
+ popup.menu.findItem(R.id.manual_pick).isEnabled = false
+
+ popup.setOnMenuItemClickListener { menuItem: MenuItem ->
+ // Respond to menu item click.
+ when (menuItem.itemId) {
+ R.id.download_cache -> {
+ if (pick != null) {
+ val pickedCache: CachedAlternative? =
+ viewModel.getCache()?.cachedAlternatives?.getOrNull(pick.second)
+ if (pickedCache != null) {
+ viewModel.triggerTorrentEvent(
+ TorrentEvent.DownloadCache(
+ pick.second + 1,
+ pickedCache.cachedFiles.count()
+ )
+ )
+ val selectedFiles =
+ pickedCache.cachedFiles.joinToString(separator = ",") {
+ it.id.toString()
+ }
+
+ viewModel.startSelectionLoop(selectedFiles)
+ } else {
+ Timber.e("No cache corresponding to index ${pick.first} found")
+ }
+
+ } else {
+ Timber.e("No cache pick found")
+ }
+ }
+ R.id.download_all -> {
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadAll)
+ viewModel.startSelectionLoop()
+ }
+ R.id.manual_pick -> {
+
+ if (lastSelection != null) {
+ var counter = 0
+ val selectedFiles = StringBuffer()
+ Node.traverseBreadthFirst(lastSelection) {
+ if (it.selected && it.id != TYPE_FOLDER) {
+ selectedFiles.append(it.id)
+ selectedFiles.append(",")
+ counter++
+ }
+ }
+
+ if (counter==0) {
+ context?.showToast(R.string.select_one_item)
+ } else {
+ if (selectedFiles.last() == ","[0])
+ selectedFiles.deleteCharAt(selectedFiles.lastIndex)
+
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadSelection(counter))
+ viewModel.startSelectionLoop(selectedFiles.toString())
+ }
+ } else {
+ Timber.e("Last files selection should not have been null")
+ }
+ }
+ else -> {
+ Timber.e("Unknown menu button pressed: $menuItem")
+ }
+ }
+
+ true
+ }
+ popup.setOnDismissListener {
+ // Respond to popup being dismissed.
+ }
+ // Show the popup menu.
+ popup.show()
+ }
+
+ private fun downloadTorrentToCache(link: String) {
+ val nameRegex = "/([^/]+\\.torrent)\$"
+ val m: Matcher = Pattern.compile(nameRegex).matcher(link)
+ val torrentName = if (m.find()) m.group(1) else null
+ val cacheDir = context?.cacheDir
+ if (!torrentName.isNullOrBlank() && cacheDir != null) {
+ activityViewModel.downloadFileToCache(link, torrentName, cacheDir).observe(
+ viewLifecycleOwner
+ ) {
+ when (it) {
+ is DownloadResult.End -> {
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadedFileSuccess)
+ loadCachedTorrent(cacheDir, it.fileName)
+ }
+ DownloadResult.Failure -> {
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadedFileFailure)
+ }
+ is DownloadResult.Progress -> {
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadedFileProgress(it.percent))
+ }
+ DownloadResult.WrongURL -> {
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadedFileFailure)
+ }
+ }
+ }
+ }
+ }
+
+
+ private fun loadCachedTorrent(
+ cacheDir: File,
+ fileName: String
+ ) {
+ try {
+ val cacheFile = File(cacheDir, fileName)
+ cacheFile.inputStream().use { inputStream ->
+ val buffer: ByteArray = inputStream.readBytes()
+ viewModel.fetchUploadedTorrent(buffer)
+ }
+ } catch (exception: Exception) {
+ viewModel.triggerTorrentEvent(TorrentEvent.DownloadedFileFailure)
+ when (exception) {
+ is java.io.FileNotFoundException -> {
+ Timber.e("Torrent conversion: file not found: ${exception.message}")
+ }
+ is IOException -> {
+ Timber.e("Torrent conversion: IOException error getting the file: ${exception.message}")
+ }
+ else -> {
+ Timber.e("Torrent conversion: Other error getting the file: ${exception.message}")
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val POSITION_FILE_PICKER = 0
+ const val POSITION_CACHE_PICKER = 1
+ }
+}
+
+
+class TorrentFilePagerAdapter(
+ fragment: Fragment,
+ private val viewModel: TorrentProcessingViewModel
+) :
+ FragmentStateAdapter(fragment) {
+ override fun getItemCount(): Int = 2
+
+ override fun createFragment(position: Int): Fragment {
+ return if (position == POSITION_FILE_PICKER) {
+ TorrentFilePickerFragment.newInstance()
+ } else {
+ TorrentCachePickerFragment.newInstance(viewModel.getCache(), viewModel.getTorrentID()!!)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt
new file mode 100644
index 000000000..094e8dfbf
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt
@@ -0,0 +1,244 @@
+package com.github.livingwithhippos.unchained.torrentfilepicker.viewmodel
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.livingwithhippos.unchained.data.model.EmptyBodyError
+import com.github.livingwithhippos.unchained.data.model.TorrentItem
+import com.github.livingwithhippos.unchained.data.model.UnchainedNetworkException
+import com.github.livingwithhippos.unchained.data.model.UploadedTorrent
+import com.github.livingwithhippos.unchained.data.model.cache.CachedTorrent
+import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
+import com.github.livingwithhippos.unchained.torrentdetails.model.TorrentFileItem
+import com.github.livingwithhippos.unchained.utilities.BASE_URL
+import com.github.livingwithhippos.unchained.utilities.EitherResult
+import com.github.livingwithhippos.unchained.utilities.Event
+import com.github.livingwithhippos.unchained.utilities.INSTANT_AVAILABILITY_ENDPOINT
+import com.github.livingwithhippos.unchained.utilities.Node
+import com.github.livingwithhippos.unchained.utilities.beforeSelectionStatusList
+import com.github.livingwithhippos.unchained.utilities.extension.cancelIfActive
+import com.github.livingwithhippos.unchained.utilities.postEvent
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.*
+import javax.inject.Inject
+
+@HiltViewModel
+class TorrentProcessingViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
+ private val torrentsRepository: TorrentsRepository
+) : ViewModel() {
+
+ val networkExceptionLiveData = MutableLiveData>()
+ val torrentLiveData = MutableLiveData>()
+ val structureLiveData = MutableLiveData>>()
+
+ private var job = Job()
+
+ fun fetchAddedMagnet(magnet: String) {
+ viewModelScope.launch {
+ val availableHosts = torrentsRepository.getAvailableHosts()
+ if (availableHosts.isNullOrEmpty()) {
+ Timber.e("Error fetching available hosts")
+ } else {
+ val addedMagnet =
+ torrentsRepository.addMagnet(magnet, availableHosts.first().host)
+ when (addedMagnet) {
+ is EitherResult.Failure -> {
+ networkExceptionLiveData.postEvent(addedMagnet.failure)
+ }
+ is EitherResult.Success -> {
+ setTorrentID(addedMagnet.success.id)
+ torrentLiveData.postEvent(TorrentEvent.Uploaded(addedMagnet.success))
+ }
+ }
+ }
+ }
+ }
+
+ fun fetchTorrentDetails(torrentID: String) {
+
+ setTorrentID(torrentID)
+
+ viewModelScope.launch {
+ val torrentData: TorrentItem? = torrentsRepository.getTorrentInfo(torrentID)
+ // todo: replace using either
+ if (torrentData != null) {
+ setTorrentDetails(torrentData)
+ torrentLiveData.postEvent(TorrentEvent.TorrentInfo(torrentData))
+ } else {
+ Timber.e("Retrieved torrent info were null for id $torrentID")
+ }
+ }
+ }
+
+ fun checkTorrentCache(hash: String) {
+ viewModelScope.launch {
+ val builder = StringBuilder(BASE_URL)
+ builder.append(INSTANT_AVAILABILITY_ENDPOINT)
+ builder.append("/")
+ builder.append(hash)
+ when (val cache =
+ torrentsRepository.getInstantAvailability(builder.toString())) {
+ is EitherResult.Failure -> {
+ Timber.e("Failed getting cache for hash $hash ${cache.failure}")
+ }
+ is EitherResult.Success -> {
+ triggerCacheResult(cache.success.cachedTorrents.firstOrNull())
+ }
+ }
+ }
+ }
+
+ fun triggerCacheResult(cache: CachedTorrent?) {
+ if (cache != null) {
+ setCache(cache)
+ torrentLiveData.postEvent(TorrentEvent.CacheHit(cache))
+ } else {
+ torrentLiveData.postEvent(TorrentEvent.CacheMiss)
+ }
+ }
+
+ fun getTorrentDetails(): TorrentItem? {
+ return savedStateHandle[KEY_CURRENT_TORRENT]
+ }
+
+ private fun setTorrentDetails(item: TorrentItem) {
+ savedStateHandle[KEY_CURRENT_TORRENT] = item
+ }
+
+ fun getTorrentID(): String? {
+ return savedStateHandle[KEY_CURRENT_TORRENT_ID]
+ }
+
+ private fun setTorrentID(id: String) {
+ savedStateHandle[KEY_CURRENT_TORRENT_ID] = id
+ }
+
+ fun getCache(): CachedTorrent? {
+ return savedStateHandle[KEY_CACHE]
+ }
+
+ private fun setCache(cache: CachedTorrent) {
+ savedStateHandle[KEY_CACHE] = cache
+ }
+
+ fun updateTorrentStructure(structure: Node?) {
+ if (structure != null)
+ structureLiveData.postEvent(structure)
+ }
+
+ fun startSelectionLoop(files: String = "all") {
+
+ val id = getTorrentID()
+
+ if (id == null) {
+ Timber.e("Torrent files selection requested but torrent id was not ready")
+ return
+ }
+
+ job.cancelIfActive()
+ job = Job()
+
+ val scope = CoroutineScope(job + Dispatchers.IO)
+
+ scope.launch {
+
+ var selected = false
+ /// maybe job.isActive?
+ while (isActive) {
+ if (!selected) {
+ when (val selectResponse = torrentsRepository.selectFiles(id, files)) {
+ is EitherResult.Failure -> {
+ if (selectResponse.failure is EmptyBodyError) {
+ Timber.d("Select torrent files success returned ${selectResponse.failure.returnCode}")
+ selected = true
+ } else {
+ Timber.e("Exception during torrent files selection call: ${selectResponse.failure}")
+ }
+ }
+ is EitherResult.Success -> {
+ Timber.d("Select torrent files success")
+ selected = true
+ }
+ }
+ }
+
+ if (selected) {
+ val torrentItem: TorrentItem? = torrentsRepository.getTorrentInfo(id)
+ if (torrentItem != null) {
+ if (!beforeSelectionStatusList.contains(torrentItem.status)) {
+ job.cancelIfActive()
+ torrentLiveData.postEvent(TorrentEvent.FilesSelected(torrentItem))
+ }
+ }
+ }
+ delay(1000)
+ }
+ }
+ }
+
+ fun pollTorrentStatus() {
+ Timer().scheduleAtFixedRate(object : TimerTask() {
+ override fun run() {
+ // check if it goes into select files
+ // todo: create a service to do this, check the download one
+ }
+ }, 0, 1000)
+ }
+
+ fun triggerTorrentEvent(event: TorrentEvent) {
+ torrentLiveData.postEvent(event)
+ }
+
+
+ fun fetchUploadedTorrent(binaryTorrent: ByteArray) {
+ viewModelScope.launch {
+ val availableHosts = torrentsRepository.getAvailableHosts()
+ if (availableHosts.isNullOrEmpty()) {
+ Timber.e("Error fetching available hosts")
+ torrentLiveData.postEvent(TorrentEvent.DownloadedFileFailure)
+ } else {
+ val uploadedTorrent =
+ torrentsRepository.addTorrent(binaryTorrent, availableHosts.first().host)
+ when (uploadedTorrent) {
+ is EitherResult.Failure -> {
+ networkExceptionLiveData.postEvent(uploadedTorrent.failure)
+ torrentLiveData.postEvent(TorrentEvent.DownloadedFileFailure)
+ }
+ is EitherResult.Success -> {
+ fetchTorrentDetails(uploadedTorrent.success.id)
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val KEY_CACHE = "cache_key"
+ const val KEY_CURRENT_TORRENT = "current_torrent_key"
+ const val KEY_CURRENT_TORRENT_ID = "current_torrent_id_key"
+ const val KEY_CURRENT_TORRENT_STRUCTURE = "current_torrent_structure_key"
+ }
+}
+
+sealed class TorrentEvent {
+ data class Uploaded(val torrent: UploadedTorrent) : TorrentEvent()
+ data class TorrentInfo(val item: TorrentItem) : TorrentEvent()
+ data class CacheHit(val cache: CachedTorrent) : TorrentEvent()
+ object CacheMiss : TorrentEvent()
+ data class FilesSelected(val torrent: TorrentItem) : TorrentEvent()
+ object DownloadAll : TorrentEvent()
+ data class DownloadCache(val position: Int, val files: Int) : TorrentEvent()
+ data class DownloadSelection(val filesNumber: Int) : TorrentEvent()
+ object DownloadedFileSuccess : TorrentEvent()
+ object DownloadedFileFailure : TorrentEvent()
+ data class DownloadedFileProgress(val progress: Int) : TorrentEvent()
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/user/view/UserProfileFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/user/view/UserProfileFragment.kt
index 0e841ed22..65252fc28 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/user/view/UserProfileFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/user/view/UserProfileFragment.kt
@@ -1,10 +1,14 @@
package com.github.livingwithhippos.unchained.user.view
+import android.Manifest
import android.content.SharedPreferences
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.core.content.PermissionChecker
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
@@ -73,21 +77,21 @@ class UserProfileFragment : UnchainedFragment() {
putBoolean(KEY_REFERRAL_USE, false)
apply()
}
- openExternalWebPage(ACCOUNT_LINK)
+ context?.openExternalWebPage(ACCOUNT_LINK)
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
with(preferences.edit()) {
putBoolean(KEY_REFERRAL_USE, true)
apply()
}
- openExternalWebPage(REFERRAL_LINK)
+ context?.openExternalWebPage(REFERRAL_LINK)
}
.show()
} else {
if (preferences.getBoolean(KEY_REFERRAL_USE, false))
- openExternalWebPage(REFERRAL_LINK)
+ context?.openExternalWebPage(REFERRAL_LINK)
else
- openExternalWebPage(ACCOUNT_LINK)
+ context?.openExternalWebPage(ACCOUNT_LINK)
}
}
@@ -125,6 +129,16 @@ class UserProfileFragment : UnchainedFragment() {
activityViewModel.logout()
}
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PermissionChecker.PERMISSION_GRANTED
+ ) {
+ activityViewModel.requireNotificationPermissions()
+ }
+
return userBinding.root
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt
index 07898bceb..5cbe01d3d 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt
@@ -1,15 +1,19 @@
package com.github.livingwithhippos.unchained.utilities
+import com.github.livingwithhippos.unchained.R
+
const val OPEN_SOURCE_CLIENT_ID = "X245A4XAIBGVM"
const val OPEN_SOURCE_GRANT_TYPE = "http://oauth.net/grant_type/device/1.0"
const val BASE_URL = "https://api.real-debrid.com/rest/1.0/"
const val BASE_AUTH_URL = "https://api.real-debrid.com/oauth/v2/"
+const val INSTANT_AVAILABILITY_ENDPOINT = "torrents/instantAvailability"
const val REFERRAL_LINK = "http://real-debrid.com/?id=78841"
const val PREMIUM_LINK = "https://real-debrid.com/premium"
const val ACCOUNT_LINK = "https://real-debrid.com/account"
-const val PLUGINS_PACK_LINK = "https://github.com/LivingWithHippos/unchained-android/raw/master/extra_assets/plugins/unchained_plugins_pack.zip"
+const val PLUGINS_PACK_LINK =
+ "https://github.com/LivingWithHippos/unchained-android/raw/master/extra_assets/plugins/unchained_plugins_pack.zip"
const val PLUGINS_PACK_NAME = "unchained_plugins_pack.zip"
const val PLUGINS_PACK_FOLDER = "pack"
@@ -23,7 +27,7 @@ const val PRIVATE_TOKEN: String = "private_token"
const val REMOTE_TRAFFIC_OFF: Int = 0
const val REMOTE_TRAFFIC_ON: Int = 1
-const val MAGNET_PATTERN: String = "magnet:\\?xt=urn:btih:[a-zA-Z0-9]{32}"
+const val MAGNET_PATTERN: String = "magnet:\\?xt=urn:btih:([a-zA-Z0-9]{32,})"
const val TORRENT_PATTERN: String = "https?://[^\\s]{7,}\\.torrent"
const val CONTAINER_PATTERN: String = "https?://[^\\s]{7,}\\.(rsdf|ccf3|ccf|dlc)"
const val CONTAINER_EXTENSION_PATTERN: String = "[^\\s]+\\.(rsdf|ccf3|ccf|dlc)$"
@@ -75,11 +79,17 @@ val errorMap = mapOf(
)
// Torrent status list
+// possible status are magnet_error, magnet_conversion, waiting_files_selection,
+// queued, downloading, downloaded, error, virus, compressing, uploading, dead
+/**
+ * Statuses the torrent is not going to advance from
+ */
val endedStatusList = listOf("magnet_error", "downloaded", "error", "virus", "dead")
-// possible status are magnet_error, magnet_conversion, waiting_files_selection,
-// queued, downloading, downloaded, error, virus, compressing, uploading, dead
+/**
+ * Statuses the torrent will advance from
+ */
val loadingStatusList = listOf(
"downloading",
"magnet_conversion",
@@ -89,5 +99,113 @@ val loadingStatusList = listOf(
"uploading"
)
+/**
+ * Statuses where the torrent hasn't had its file selected yet
+ */
+val beforeSelectionStatusList = listOf(
+ "magnet_conversion",
+ "waiting_files_selection",
+)
+
const val DOWNLOADS_TAB = 0
const val TORRENTS_TAB = 1
+
+object SIGNATURE {
+ const val URL =
+ "https://gist.githubusercontent.com/LivingWithHippos/5525e73f0439d06c1c3ff4f9484e35dd/raw/f97b79e706aa67d729806039d49f80aba4042793/unchained_versions.json"
+ const val PLAY_STORE = "31F17448AA3888B63ED04EB5F965E3F70C12592F"
+ const val F_DROID = "412DABCABBDB75A82FF66F767C71EE045C02275B"
+ const val GITHUB = "0E7BE3FA6B47C20394517C568570E10761A0A4FA"
+}
+
+object APP_LINK {
+ const val PLAY_STORE =
+ "https://play.google.com/store/apps/details?id=com.github.livingwithhippos.unchained"
+ const val F_DROID = "https://f-droid.org/packages/com.github.livingwithhippos.unchained/"
+ const val GITHUB = "https://github.com/LivingWithHippos/unchained-android/releases"
+}
+
+object PreferenceKeys {
+ // todo: move all keys here
+ object DownloadManager {
+ const val KEY = "download_manager"
+ const val SYSTEM = "download_manager_system"
+ const val OKHTTP = "download_manager_okhttp"
+ const val UNMETERED_ONLY_KEY = "download_only_on_unmetered"
+ }
+}
+
+/**
+ * Used to map file extension and their icon
+ */
+val extensionIconMap: Map = mapOf(
+ // this will be used as default value if no extension is recognized
+ "default" to R.drawable.icon_file,
+ // ARCHIVES
+ "zip" to R.drawable.icon_archive,
+ "rar" to R.drawable.icon_archive,
+ "7z" to R.drawable.icon_archive,
+ "tar" to R.drawable.icon_archive,
+ "gz" to R.drawable.icon_archive,
+ "arj" to R.drawable.icon_archive,
+ "deb" to R.drawable.icon_archive,
+ "pkg" to R.drawable.icon_archive,
+ "rpm" to R.drawable.icon_archive,
+ // AUDIO
+ "aif" to R.drawable.icon_audio,
+ "cda" to R.drawable.icon_audio,
+ "mid" to R.drawable.icon_audio,
+ "midi" to R.drawable.icon_audio,
+ "mp3" to R.drawable.icon_audio,
+ "mpa" to R.drawable.icon_audio,
+ "ogg" to R.drawable.icon_audio,
+ "wav" to R.drawable.icon_audio,
+ "wma" to R.drawable.icon_audio,
+ "wpl" to R.drawable.icon_audio,
+ // PICTURES
+ "ai" to R.drawable.icon_image,
+ "bmp" to R.drawable.icon_image,
+ "gif" to R.drawable.icon_image,
+ "ico" to R.drawable.icon_image,
+ "jpeg" to R.drawable.icon_image,
+ "jpg" to R.drawable.icon_image,
+ "png" to R.drawable.icon_image,
+ "psd" to R.drawable.icon_image,
+ "ps" to R.drawable.icon_image,
+ "svg" to R.drawable.icon_image,
+ "tiff" to R.drawable.icon_image,
+ "tif" to R.drawable.icon_image,
+ "raw" to R.drawable.icon_image,
+ // VIDEOS
+ "3g2" to R.drawable.icon_movie,
+ "3gp" to R.drawable.icon_movie,
+ "avi" to R.drawable.icon_movie,
+ "flv" to R.drawable.icon_movie,
+ "h264" to R.drawable.icon_movie,
+ "m4v" to R.drawable.icon_movie,
+ "mkv" to R.drawable.icon_movie,
+ "mov" to R.drawable.icon_movie,
+ "mp4" to R.drawable.icon_movie,
+ "mpg" to R.drawable.icon_movie,
+ "mpeg" to R.drawable.icon_movie,
+ "rm" to R.drawable.icon_movie,
+ "swf" to R.drawable.icon_movie,
+ "vob" to R.drawable.icon_movie,
+ "wmv" to R.drawable.icon_movie,
+ // CAPTIONS
+ "srt" to R.drawable.icon_subtitles,
+ "scc" to R.drawable.icon_subtitles,
+ "vtt" to R.drawable.icon_subtitles,
+ "itt" to R.drawable.icon_subtitles,
+ "smi" to R.drawable.icon_subtitles,
+ "sami" to R.drawable.icon_subtitles,
+ "sbv" to R.drawable.icon_subtitles,
+ "aaf" to R.drawable.icon_subtitles,
+ "mcc" to R.drawable.icon_subtitles,
+ "mxf" to R.drawable.icon_subtitles,
+ "asc" to R.drawable.icon_subtitles,
+ "stl" to R.drawable.icon_subtitles,
+ "scr" to R.drawable.icon_subtitles,
+ "sub" to R.drawable.icon_subtitles,
+ "idx" to R.drawable.icon_subtitles,
+)
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/DataBindingAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/DataBindingAdapter.kt
index 7d1acd356..443971a1d 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/DataBindingAdapter.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/DataBindingAdapter.kt
@@ -11,6 +11,7 @@ import androidx.recyclerview.selection.SelectionTracker
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.github.livingwithhippos.unchained.BR
// todo: test implementing class with Nothing as generic value to avoid passing listeners
@@ -20,7 +21,7 @@ import com.github.livingwithhippos.unchained.BR
*/
abstract class DataBindingAdapter(
diffCallback: DiffUtil.ItemCallback,
- val listener: U? = null
+ val listener: U
) :
ListAdapter>(diffCallback) {
@@ -31,8 +32,9 @@ abstract class DataBindingAdapter(
return DataBindingViewHolder(binding)
}
- override fun onBindViewHolder(holder: DataBindingViewHolder, position: Int) =
+ override fun onBindViewHolder(holder: DataBindingViewHolder, position: Int) {
holder.bind(getItem(position), listener)
+ }
}
/**
@@ -66,6 +68,50 @@ abstract class DataBindingTrackedAdapter(
}
}
+class DataBindingViewHolder(private val binding: ViewDataBinding) :
+ ViewHolder(binding.root) {
+
+ fun bind(item: T, listener: U?) {
+ binding.setVariable(BR.item, item)
+ if (listener != null)
+ binding.setVariable(BR.listener, listener)
+ binding.executePendingBindings()
+ }
+}
+
+/**
+ * A [DataBindingViewHolder] subclass.
+ * Allows for a generic list of items with data binding without listeners
+ */
+abstract class DataBindingStaticAdapter(
+ diffCallback: DiffUtil.ItemCallback
+) :
+ ListAdapter>(diffCallback) {
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): DataBindingStaticViewHolder {
+ val layoutInflater = LayoutInflater.from(parent.context)
+ val binding =
+ DataBindingUtil.inflate(layoutInflater, viewType, parent, false)
+ return DataBindingStaticViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: DataBindingStaticViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+}
+
+class DataBindingStaticViewHolder(private val binding: ViewDataBinding) :
+ ViewHolder(binding.root) {
+
+ fun bind(item: T) {
+ binding.setVariable(BR.item, item)
+ binding.executePendingBindings()
+ }
+}
+
/**
* A [PagingDataAdapter] subclass.
* Allows for a generic list of items with data binding and Paging support and an optional listener.
@@ -91,16 +137,6 @@ abstract class DataBindingPagingAdapter(
}
}
-class DataBindingViewHolder(private val binding: ViewDataBinding) :
- RecyclerView.ViewHolder(binding.root) {
-
- fun bind(item: T, listener: U?) {
- binding.setVariable(BR.item, item)
- if (listener != null)
- binding.setVariable(BR.listener, listener)
- binding.executePendingBindings()
- }
-}
/**
* A [PagingDataAdapter] subclass.
@@ -133,7 +169,7 @@ abstract class DataBindingPagingTrackedAdapter(
}
class DataBindingTrackedViewHolder(private val binding: ViewDataBinding) :
- RecyclerView.ViewHolder(binding.root) {
+ ViewHolder(binding.root) {
var mItem: T? = null
@@ -163,4 +199,4 @@ class DataBindingDetailsLookup(private val recyclerView: RecyclerView) :
}
return null
}
-}
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Node.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Node.kt
new file mode 100644
index 000000000..6f4baa505
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Node.kt
@@ -0,0 +1,90 @@
+package com.github.livingwithhippos.unchained.utilities
+
+/**
+ * Credits to [sources](https://github.com/montwell/KotlinTreeTraversals) and [article](https://medium.com/swlh/tree-traversals-in-kotlin-7ff1940af7fa)
+ *
+ * @param T
+ * @property value
+ * @property children
+ * @constructor Create empty Node
+ */
+class Node(
+ var value: T,
+ var children: MutableList> = mutableListOf()
+) {
+
+ companion object {
+
+ fun traverseDepthFirst(
+ rootNode: Node,
+ action: (value: T) -> Unit
+ ) {
+ val stack = ArrayDeque>()
+ stack.addFirst(rootNode)
+
+ while (stack.isNotEmpty()) {
+ val currentNode = stack.removeFirst()
+
+ action.invoke(currentNode.value)
+
+ for (index in currentNode.children.size - 1 downTo 0) {
+ stack.addFirst(currentNode.children[index])
+ }
+ }
+ }
+
+ fun traverseNodeDepthFirst(
+ rootNode: Node,
+ action: (value: Node) -> Unit
+ ) {
+ val stack = ArrayDeque>()
+ stack.addFirst(rootNode)
+
+ while (stack.isNotEmpty()) {
+ val currentNode = stack.removeFirst()
+
+ action.invoke(currentNode)
+
+ for (index in currentNode.children.size - 1 downTo 0) {
+ stack.addFirst(currentNode.children[index])
+ }
+ }
+ }
+
+ fun traverseBreadthFirst(
+ rootNode: Node,
+ action: (value: T) -> Unit
+ ) {
+ val queue = ArrayDeque>()
+ queue.addFirst(rootNode)
+
+ while (queue.isNotEmpty()) {
+ val currentNode = queue.removeLast()
+
+ action.invoke(currentNode.value)
+
+ for (childNode in currentNode.children) {
+ queue.addFirst(childNode)
+ }
+ }
+ }
+
+ fun traverseNodeBreadthFirst(
+ rootNode: Node,
+ action: (value: Node) -> Unit
+ ) {
+ val queue = ArrayDeque>()
+ queue.addFirst(rootNode)
+
+ while (queue.isNotEmpty()) {
+ val currentNode = queue.removeLast()
+
+ action.invoke(currentNode)
+
+ for (childNode in currentNode.children) {
+ queue.addFirst(childNode)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Regex.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Regex.kt
new file mode 100644
index 000000000..713802d78
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Regex.kt
@@ -0,0 +1,27 @@
+package com.github.livingwithhippos.unchained.utilities
+
+val kbPattern = "\\s*(\\d\\d*\\.?\\d*)\\s*[kK]".toRegex()
+val mbPattern = "\\s*(\\d\\d*\\.?\\d*)\\s*[mM]".toRegex()
+val gbPattern = "\\s*(\\d\\d*\\.?\\d*)\\s*[gG]".toRegex()
+val genericPatter = "\\d\\d*\\.?\\d*".toRegex()
+
+fun parseCommonSize(rawSize: String?): Double? {
+ try {
+ if (rawSize.isNullOrBlank())
+ return null
+ var match = kbPattern.find(rawSize)?.groupValues?.get(1)
+ if (match != null)
+ return match.toDouble() / 1024
+ match = mbPattern.find(rawSize)?.groupValues?.get(1)
+ if (match != null)
+ return match.toDouble()
+ match = gbPattern.find(rawSize)?.groupValues?.get(1)
+ if (match != null)
+ return match.toDouble() * 1024
+
+ match = genericPatter.find(rawSize)?.value
+ return match?.toDouble()
+ } catch (e: NumberFormatException) {
+ return null
+ }
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/UnzipUtils.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/UnzipUtils.kt
index 62070d02a..7deb4c2c1 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/UnzipUtils.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/UnzipUtils.kt
@@ -5,7 +5,6 @@ import java.io.IOException
import java.io.InputStream
import java.util.zip.ZipFile
-
/**
* UnzipUtils class extracts files and sub-directories of a standard zip file to
* a destination directory.
@@ -39,9 +38,7 @@ object UnzipUtils {
// if the entry is a directory, make the directory
filePath.mkdir()
}
-
}
-
}
}
}
@@ -58,4 +55,4 @@ object UnzipUtils {
inputStream.copyTo(it)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/DownloadWorker.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/DownloadWorker.kt
new file mode 100644
index 000000000..486524d35
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/DownloadWorker.kt
@@ -0,0 +1,367 @@
+package com.github.livingwithhippos.unchained.utilities.download
+
+import android.app.Notification
+import android.content.Context
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.documentfile.provider.DocumentFile
+import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.base.UnchainedApplication
+import com.github.livingwithhippos.unchained.start.viewmodel.MainActivityViewModel
+import com.github.livingwithhippos.unchained.utilities.extension.showToast
+import com.github.livingwithhippos.unchained.utilities.extension.vibrate
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import timber.log.Timber
+import java.net.URLConnection
+import java.util.*
+
+class DownloadWorker(appContext: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(appContext, workerParams) {
+
+ private var job = Job()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+ var shutdown = false
+
+ override suspend fun doWork(): Result {
+ val sourceUrl: String = inputData.getString(MainActivityViewModel.KEY_DOWNLOAD_SOURCE)
+ ?: return Result.failure()
+ val fileName: String =
+ inputData.getString(MainActivityViewModel.KEY_DOWNLOAD_NAME) ?: return Result.failure()
+
+ val folderUri: Uri =
+ Uri.parse(inputData.getString(MainActivityViewModel.KEY_FOLDER_URI)!!)
+
+ val newFile: DocumentFile? = try {
+ getFileDocument(sourceUrl, folderUri, fileName)
+ } catch (ex: SecurityException) {
+ Timber.e("User has removed folder permissions")
+ null
+ }
+
+ if (newFile == null) {
+ Timber.e("Error getting download location file")
+ showToast(R.string.pick_download_folder)
+ return Result.failure()
+ }
+
+ // this id is used with setForeground, any notification with this id will be removed when the worker ends
+ val notificationID = newFile.hashCode()
+ // this id is used for notifications that shouldn't be removed when the worker stops (e.g. download stopped/crashed/completed)
+ val externalNotificationID = sourceUrl.hashCode()
+
+ try {
+ val outputStream = applicationContext.contentResolver.openOutputStream(newFile.uri)
+ if (outputStream == null) {
+ Timber.e("Error getting download uri")
+ showToast(R.string.download_queued_error)
+ return Result.failure()
+ }
+
+ // todo: use a single customized instance of this
+ val client = OkHttpClient()
+ val writer = FileWriter(
+ outputStream
+ )
+ val downloader = Downloader(
+ client,
+ writer
+ )
+
+ var progressCounter = -1
+ var lastNotificationTime = 0L
+
+ scope.launch {
+ writer.state.collect {
+ if (!isStopped) {
+ when (it) {
+ DownloadStatus.Completed -> {
+ // this is managed below
+ }
+ is DownloadStatus.Error -> {
+ val notification = makeStatusNotification(
+ id,
+ fileName,
+ applicationContext.getString(R.string.error),
+ applicationContext,
+ onGoing = false,
+ stopAction = false
+ )
+ NotificationManagerCompat.from(applicationContext)
+ .notify(externalNotificationID, notification)
+ }
+ DownloadStatus.Paused -> {
+ setForeground(
+ makeStatusForegroundInfo(
+ id,
+ notificationID,
+ fileName,
+ applicationContext.getString(R.string.paused),
+ applicationContext
+ )
+ )
+ }
+ DownloadStatus.Queued -> {
+ setForeground(
+ makeStatusForegroundInfo(
+ id,
+ notificationID,
+ fileName,
+ applicationContext.getString(R.string.queued),
+ applicationContext
+ )
+ )
+ }
+ DownloadStatus.Stopped -> {
+ val notification = makeStatusNotification(
+ id,
+ fileName,
+ applicationContext.getString(R.string.stopped),
+ applicationContext,
+ onGoing = false,
+ stopAction = false
+ )
+ NotificationManagerCompat.from(applicationContext)
+ .notify(externalNotificationID, notification)
+ }
+ is DownloadStatus.Running -> {
+ if (it.percent < 100 && it.percent != progressCounter && System.currentTimeMillis() - lastNotificationTime > 500 && !isStopped) {
+ lastNotificationTime = System.currentTimeMillis()
+ progressCounter = it.percent
+
+
+ setForeground(
+ makeProgressForegroundInfo(
+ id,
+ notificationID,
+ fileName,
+ it.percent,
+ applicationContext
+ )
+ )
+
+ }
+ }
+ }
+ } else {
+ // stops the download only the first time I get a notification
+ if (!shutdown) {
+ shutdown = true
+ downloader.stop()
+ }
+ }
+ }
+ }
+
+ showToast(R.string.download_queued)
+ // this needs to be blocking, see https://developer.android.com/topic/libraries/architecture/workmanager/advanced/coroutineworker
+ val downloadedSize: Long = downloader.download(sourceUrl)
+
+ // todo: get whole size and check if it correspond
+ return if (downloadedSize > 0) {
+ // returning a result will terminate the worker and any notification created with setForeground will disappear,
+ // so we use the system notification manager to notify of completed/stopped/error downloads
+
+ val notification = makeStatusNotification(
+ id,
+ fileName,
+ applicationContext.getString(R.string.download_complete),
+ applicationContext,
+ onGoing = false,
+ stopAction = false
+ )
+ NotificationManagerCompat.from(applicationContext)
+ .notify(externalNotificationID, notification)
+ applicationContext.vibrate()
+ Result.success()
+ } else
+ Result.failure()
+ } catch (e: android.accounts.NetworkErrorException) {
+ e.printStackTrace()
+
+ showToast(R.string.download_link_expired)
+ Timber.e("Exception occurred while downloading, ${e.message}")
+ return Result.failure()
+ } catch (e: java.lang.Exception) {
+ e.printStackTrace()
+
+ showToast(
+ applicationContext.getString(
+ R.string.download_not_started_format,
+ fileName
+ )
+ )
+ Timber.e("Exception occurred while downloading, ${e.message}")
+ return Result.failure()
+ }
+ }
+
+ private suspend fun showToast(stringId: Int) = withContext(Dispatchers.Main) {
+ applicationContext.showToast(stringId)
+ }
+
+ private suspend fun showToast(message: String) = withContext(Dispatchers.Main) {
+ applicationContext.showToast(message)
+ }
+
+ private fun getFileDocument(
+ sourceUrl: String,
+ destinationFolder: Uri,
+ fileName: String
+ ): DocumentFile? {
+
+ val folderUri: DocumentFile? =
+ DocumentFile.fromTreeUri(applicationContext, destinationFolder)
+ if (folderUri != null) {
+ val extension: String = MimeTypeMap.getFileExtensionFromUrl(sourceUrl)
+ var mime: String? = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+ if (mime == null) {
+ mime = URLConnection.guessContentTypeFromName(sourceUrl)
+ /*
+ if (mime == null) {
+ val connection: URLConnection = URL(link).openConnection()
+ mime= connection.contentType
+ }
+ */
+ if (mime == null) {
+ // todo: use other checks or a random mime type
+ mime = "*/*"
+ }
+ }
+ // todo: check if the extension needs to be removed as the docs say (it does not seem to)
+ return folderUri.createFile(mime, fileName)
+ } else {
+ Timber.e("folderUri was null")
+ return null
+ }
+ }
+}
+
+const val GROUP_KEY_DOWNLOADS: String = "com.github.livingwithhippos.unchained.DOWNLOADS"
+
+
+// see https://developer.android.com/topic/libraries/architecture/workmanager/advanced/long-running
+fun makeStatusNotification(
+ workerId: UUID,
+ filename: String,
+ title: String,
+ context: Context,
+ onGoing: Boolean = true,
+ stopAction: Boolean = true
+): Notification {
+
+ // Create the notification
+ return NotificationCompat.Builder(context, UnchainedApplication.DOWNLOAD_CHANNEL_ID)
+ .setSmallIcon(R.drawable.logo_no_background)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setGroup(GROUP_KEY_DOWNLOADS)
+ // setting setGroupSummary(false) will prevent this from showing up after the makeProgressStatusNotification one
+ .setGroupSummary(true)
+ .setProgress(0, 0, false)
+ .setOngoing(onGoing)
+ .setContentTitle(title)
+ // todo:check if .setContentText(progress) is the same
+ .setStyle(NotificationCompat.BigTextStyle().bigText(filename)).apply {
+ if (stopAction) {
+ // This PendingIntent can be used to cancel the worker
+ val stopIntent = WorkManager.getInstance(context)
+ .createCancelPendingIntent(workerId)
+
+ addAction(R.drawable.icon_stop, context.getString(R.string.stop), stopIntent)
+ }
+ }
+ .build()
+
+}
+
+fun makeStatusForegroundInfo(
+ workerId: UUID,
+ id: Int,
+ filename: String,
+ title: String,
+ context: Context,
+ onGoing: Boolean = true
+): ForegroundInfo {
+ val notification = makeStatusNotification(workerId, filename, title, context, onGoing)
+ return ForegroundInfo(id, notification)
+}
+
+fun makeProgressStatusNotification(
+ workerId: UUID,
+ filename: String,
+ progress: Int,
+ context: Context,
+ stopAction: Boolean = true
+): Notification {
+ val title = context.getString(
+ R.string.download_in_progress_format,
+ progress
+ )
+ // Create the notification
+ return NotificationCompat.Builder(context, UnchainedApplication.DOWNLOAD_CHANNEL_ID)
+ .setSmallIcon(R.drawable.logo_no_background)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setGroup(GROUP_KEY_DOWNLOADS)
+ .setGroupSummary(true)
+ .setOngoing(true)
+ .setProgress(100, progress, false)
+ .setContentTitle(title)
+ // todo:check if .setContentText(progress) is the same
+ .setStyle(NotificationCompat.BigTextStyle().bigText(filename))
+ .apply {
+ if (stopAction) {
+ // This PendingIntent can be used to cancel the worker
+ val stopIntent = WorkManager.getInstance(context)
+ .createCancelPendingIntent(workerId)
+
+ addAction(R.drawable.icon_stop, context.getString(R.string.stop), stopIntent)
+ }
+ }
+ .build()
+
+}
+
+fun makeProgressForegroundInfo(
+ workerId: UUID,
+ id: Int,
+ filename: String,
+ progress: Int,
+ context: Context
+): ForegroundInfo {
+ val notification = makeProgressStatusNotification(workerId, filename, progress, context)
+ return ForegroundInfo(id, notification)
+}
+
+sealed class DownloadStatus {
+ object Queued : DownloadStatus()
+ object Stopped : DownloadStatus()
+ object Paused : DownloadStatus()
+ object Completed : DownloadStatus()
+ data class Running(
+ val totalSize: Double,
+ val downloadedSize: Long,
+ val percent: Int,
+ ) : DownloadStatus()
+
+ data class Error(val type: DownloadErrorType) : DownloadStatus()
+}
+
+sealed class DownloadErrorType {
+ object ResponseError : DownloadErrorType()
+ object Interrupted : DownloadErrorType()
+ object EmptyBody : DownloadErrorType()
+ object ServerUnavailable : DownloadErrorType()
+ object IPBanned : DownloadErrorType()
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/Downloader.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/Downloader.kt
new file mode 100644
index 000000000..2c9471a59
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/Downloader.kt
@@ -0,0 +1,47 @@
+package com.github.livingwithhippos.unchained.utilities.download
+
+import android.accounts.NetworkErrorException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+
+/**
+ * Taken from https://www.baeldung.com/java-okhttp-download-binary-file
+ */
+class Downloader(
+ private val client: OkHttpClient,
+ private val writer: FileWriter
+) : AutoCloseable {
+
+ @Throws(IOException::class)
+ suspend fun download(url: String): Long = withContext(Dispatchers.IO) {
+ val request: Request = Request.Builder().url(url).build()
+ client.newCall(request).execute()
+ .use { response ->
+ if (response.isSuccessful) {
+ val responseBody = response.body
+ if (responseBody != null) {
+ val length: Double =
+ response.header("Content-Length", "1")?.toDouble() ?: 1.toDouble()
+
+ return@withContext writer.write(responseBody.byteStream(), length)
+ } else {
+ throw IllegalStateException("Response doesn't contain a file")
+ }
+ } else {
+ throw NetworkErrorException("Response not successful: ${response.code}")
+ }
+ }
+ }
+
+ @Throws(Exception::class)
+ override fun close() {
+ writer.close()
+ }
+
+ fun stop() {
+ writer.stopDownload = true
+ }
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/FileWriter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/FileWriter.kt
new file mode 100644
index 000000000..6a11c448a
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/download/FileWriter.kt
@@ -0,0 +1,61 @@
+package com.github.livingwithhippos.unchained.utilities.download
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
+import java.io.BufferedInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+/**
+ * Taken from https://www.baeldung.com/java-okhttp-download-binary-file
+ */
+class FileWriter(private val outputStream: OutputStream) : AutoCloseable {
+
+ private val _state: MutableStateFlow = MutableStateFlow(DownloadStatus.Queued)
+ val state: StateFlow = _state
+
+ var stopDownload = false
+
+ @Throws(IOException::class)
+ suspend fun write(inputStream: InputStream, length: Double): Long =
+ withContext(Dispatchers.IO) {
+ BufferedInputStream(inputStream).use { input ->
+ val dataBuffer =
+ ByteArray(CHUNK_SIZE)
+ var readBytes: Int
+ var totalBytes: Long = 0
+ while (input.read(dataBuffer).also { readBytes = it } != -1 && !stopDownload) {
+ totalBytes += readBytes.toLong()
+ outputStream.write(dataBuffer, 0, readBytes)
+ _state.emit(
+ DownloadStatus.Running(
+ length,
+ totalBytes,
+ (totalBytes / length * 100).toInt()
+ )
+ )
+ }
+ if (!stopDownload)
+ _state.emit(DownloadStatus.Completed)
+ else
+ _state.emit(DownloadStatus.Stopped)
+ return@withContext totalBytes
+ }
+ }
+
+ @Throws(IOException::class)
+ override fun close() {
+ outputStream.close()
+ }
+
+ companion object {
+ private const val CHUNK_SIZE = 1024
+ }
+}
+
+interface ProgressCallback {
+ fun onProgress(progress: Double)
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt
index 659caf0fa..9209d43e9 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.app.DownloadManager
import android.content.ClipData
+import android.content.ClipDescription.MIMETYPE_TEXT_HTML
import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN
import android.content.ClipboardManager
import android.content.ContentResolver.SCHEME_CONTENT
@@ -105,66 +106,48 @@ fun Fragment.copyToClipboard(label: String, text: String) {
fun Fragment.getClipboardText(): String {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
var text = ""
- if (clipboard.hasPrimaryClip() && clipboard.primaryClipDescription?.hasMimeType(
- MIMETYPE_TEXT_PLAIN
- ) == true
+ if (
+ clipboard.hasPrimaryClip()
+ && (
+ clipboard.primaryClipDescription?.hasMimeType(
+ MIMETYPE_TEXT_PLAIN
+ ) == true
+ || clipboard.primaryClipDescription?.hasMimeType(
+ MIMETYPE_TEXT_HTML
+ ) == true
+ )
) {
val item = clipboard.primaryClip!!.getItemAt(0)
text = item.text.toString()
} else {
- Timber.d("Clipboard was empty or did not contain any text mime type.")
+ Timber.d("Clipboard was empty or did not contain any text mime type: ${clipboard.primaryClipDescription}")
}
return text
}
-/**
- * Download a file in the public download folder
- *
- * @param link the http link
- * @param title the title to show on the notification
- * @param description the title to show on the notification
- * @param fileName the name to give to the downloaded file, title will be used if this is null
- * @param directory the public directory destination. Defaults to the Downloads directory
- * @return a Long identifying the download or null if an error has occurred
- */
-fun DownloadManager.downloadFile(
- link: String,
- title: String,
- description: String,
- fileName: String = title,
- directory: String = Environment.DIRECTORY_DOWNLOADS
-): EitherResult = this.downloadFile(
- Uri.parse(link),
- title,
- description,
- fileName,
- directory
-)
-
// todo: move extensions to own file base on dependencies, for example for these ones Either is needed
/**
* Download a file in the public download folder
*
- * @param uri the file Uri
+ * @param source the file Uri
* @param title the title to show on the notification
* @param description the title to show on the notification
* @param fileName the name to give to the downloaded file, title will be used if this is null
* @param directory the public directory destination. Defaults to the Downloads directory
* @return a Long identifying the download or null if an error has occurred
*/
-fun DownloadManager.downloadFile(
- uri: Uri,
+fun DownloadManager.downloadFileInStandardFolder(
+ source: Uri,
title: String,
description: String,
- fileName: String = title,
- directory: String = Environment.DIRECTORY_DOWNLOADS
+ fileName: String = title
): EitherResult {
- val request: DownloadManager.Request = DownloadManager.Request(uri)
+ val request: DownloadManager.Request = DownloadManager.Request(source)
.setTitle(title)
.setDescription(description)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(
- directory,
+ Environment.DIRECTORY_DOWNLOADS,
fileName
)
@@ -172,7 +155,33 @@ fun DownloadManager.downloadFile(
val downloadID = this.enqueue(request)
EitherResult.Success(downloadID)
} catch (e: Exception) {
- Timber.e("Error starting download of ${uri.path}, exception ${e.message}")
+ Timber.e("Error starting download of ${source.path}, exception ${e.message}")
+ EitherResult.Failure(e)
+ }
+}
+
+fun DownloadManager.downloadFileInCustomFolder(
+ source: Uri,
+ title: String,
+ description: String,
+ fileName: String = title,
+ folder: Uri
+): EitherResult {
+ // https://issuetracker.google.com/issues/134858946
+ val destination = Uri.withAppendedPath(folder, fileName)
+ Timber.e(destination.toString())
+ val request: DownloadManager.Request = DownloadManager.Request(source)
+ .setTitle(title)
+ .setDescription(description)
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setDestinationUri(destination)
+
+ return try {
+ val downloadID = this.enqueue(request)
+ EitherResult.Success(downloadID)
+ } catch (e: Exception) {
+ Timber.e("Error starting download in custom folder $destination of ${source.path}, exception ${e.message}")
+ e.printStackTrace()
EitherResult.Failure(e)
}
}
@@ -218,14 +227,15 @@ fun Uri.getFileName(context: Context): String {
* @param showErrorToast: set to true if an error toast should be displayed
* @return true if the passed url is correct, false otherwise
*/
-fun Fragment.openExternalWebPage(url: String, showErrorToast: Boolean = true): Boolean {
+fun Context.openExternalWebPage(url: String, showErrorToast: Boolean = true): Boolean {
+ // todo: check if app supporting this index are available, otherwise android.content.ActivityNotFoundException can be thrown by this
// this pattern accepts everything that is something.tld since there were too many new tlds and Google gave up updating their regex
if (url.isWebUrl()) {
val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(webIntent)
return true
} else if (showErrorToast)
- context?.showToast(R.string.invalid_url)
+ showToast(R.string.invalid_url)
return false
}
@@ -428,3 +438,5 @@ fun AssetManager.smartList(path: String): Array? {
return this.list(path.dropLast(1))
return result
}
+
+fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/NavigationExtension.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/NavigationExtension.kt
deleted file mode 100644
index c7bc9d525..000000000
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/NavigationExtension.kt
+++ /dev/null
@@ -1,255 +0,0 @@
-package com.github.livingwithhippos.unchained.utilities.extension
-
-/*
- * Copyright 2019, The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// This file has been modified
-
-import android.content.Intent
-import android.util.SparseArray
-import androidx.core.util.forEach
-import androidx.fragment.app.FragmentManager
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.navigation.NavController
-import androidx.navigation.fragment.NavHostFragment
-import com.github.livingwithhippos.unchained.R
-import com.google.android.material.bottomnavigation.BottomNavigationView
-
-/**
- * Manages the various graphs needed for a [BottomNavigationView].
- *
- * This sample is a workaround until the Navigation Component supports multiple back stacks.
- */
-fun BottomNavigationView.setupWithNavController(
- navGraphIds: List,
- fragmentManager: FragmentManager,
- containerId: Int,
- intent: Intent
-): LiveData {
-
- // Map of tags
- val graphIdToTagMap = SparseArray()
- // Result. Mutable live data with the selected controlled
- val selectedNavController = MutableLiveData()
-
- var firstFragmentGraphId = 0
-
- // First create a NavHostFragment for each NavGraph ID
- navGraphIds.forEachIndexed { index, navGraphId ->
- val fragmentTag = getFragmentTag(index)
-
- // Find or create the Navigation host fragment
- val navHostFragment = obtainNavHostFragment(
- fragmentManager,
- fragmentTag,
- navGraphId,
- containerId
- )
-
- // Obtain its id
- val graphId = navHostFragment.navController.graph.id
-
- if (index == 0) {
- firstFragmentGraphId = graphId
- }
-
- // Save to the map
- graphIdToTagMap.put(graphId, fragmentTag)
-
- // Attach or detach nav host fragment depending on whether it's the selected item.
- if (this.selectedItemId == graphId) {
- // Update livedata with the selected graph
- selectedNavController.value = navHostFragment.navController
- attachNavHostFragment(fragmentManager, navHostFragment, index == 0)
- } else {
- detachNavHostFragment(fragmentManager, navHostFragment)
- }
- }
-
- // Now connect selecting an item with swapping Fragments
- var selectedItemTag = graphIdToTagMap[this.selectedItemId]
- val firstFragmentTag = graphIdToTagMap[firstFragmentGraphId]
- var isOnFirstFragment = selectedItemTag == firstFragmentTag
-
- // When a navigation item is selected
- setOnNavigationItemSelectedListener { item ->
- // Don't do anything if the state is state has already been saved.
- if (fragmentManager.isStateSaved) {
- false
- } else {
- val newlySelectedItemTag = graphIdToTagMap[item.itemId]
- if (selectedItemTag != newlySelectedItemTag) {
- // Pop everything above the first fragment (the "fixed start destination")
- fragmentManager.popBackStack(
- firstFragmentTag,
- FragmentManager.POP_BACK_STACK_INCLUSIVE
- )
- val selectedFragment =
- fragmentManager.findFragmentByTag(newlySelectedItemTag) as NavHostFragment
-
- // Exclude the first fragment tag because it's always in the back stack.
- if (firstFragmentTag != newlySelectedItemTag) {
- // Commit a transaction that cleans the back stack and adds the first fragment
- // to it, creating the fixed started destination.
- fragmentManager.beginTransaction()
- .setCustomAnimations(
- R.anim.nav_default_enter_anim,
- R.anim.nav_default_exit_anim,
- R.anim.nav_default_pop_enter_anim,
- R.anim.nav_default_pop_exit_anim
- )
- .attach(selectedFragment)
- .setPrimaryNavigationFragment(selectedFragment)
- .apply {
- // Detach all other Fragments
- graphIdToTagMap.forEach { _, fragmentTagIter ->
- if (fragmentTagIter != newlySelectedItemTag) {
- detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)
- }
- }
- }
- .addToBackStack(firstFragmentTag)
- .setReorderingAllowed(true)
- .commit()
- }
- selectedItemTag = newlySelectedItemTag
- isOnFirstFragment = selectedItemTag == firstFragmentTag
- selectedNavController.value = selectedFragment.navController
- true
- } else {
- false
- }
- }
- }
-
- // Optional: on item reselected, pop back stack to the destination of the graph
- setupItemReselected(graphIdToTagMap, fragmentManager)
-
- // Handle deep link
- setupDeepLinks(navGraphIds, fragmentManager, containerId, intent)
-
- // Finally, ensure that we update our BottomNavigationView when the back stack changes
- fragmentManager.addOnBackStackChangedListener {
- if (!isOnFirstFragment && !fragmentManager.isOnBackStack(firstFragmentTag)) {
- this.selectedItemId = firstFragmentGraphId
- }
-
- // Reset the graph if the currentDestination is not valid (happens when the back
- // stack is popped after using the back button).
- selectedNavController.value?.let { controller ->
- if (controller.currentDestination == null) {
- controller.navigate(controller.graph.id)
- }
- }
- }
- return selectedNavController
-}
-
-private fun BottomNavigationView.setupDeepLinks(
- navGraphIds: List,
- fragmentManager: FragmentManager,
- containerId: Int,
- intent: Intent
-) {
- navGraphIds.forEachIndexed { index, navGraphId ->
- val fragmentTag = getFragmentTag(index)
-
- // Find or create the Navigation host fragment
- val navHostFragment = obtainNavHostFragment(
- fragmentManager,
- fragmentTag,
- navGraphId,
- containerId
- )
- // Handle Intent
- if (navHostFragment.navController.handleDeepLink(intent) &&
- selectedItemId != navHostFragment.navController.graph.id
- ) {
- this.selectedItemId = navHostFragment.navController.graph.id
- }
- }
-}
-
-private fun BottomNavigationView.setupItemReselected(
- graphIdToTagMap: SparseArray,
- fragmentManager: FragmentManager
-) {
- setOnNavigationItemReselectedListener { item ->
- val newlySelectedItemTag = graphIdToTagMap[item.itemId]
- val selectedFragment =
- fragmentManager.findFragmentByTag(newlySelectedItemTag) as NavHostFragment
- val navController = selectedFragment.navController
- // Pop the back stack to the start destination of the current navController graph
- navController.popBackStack(
- navController.graph.startDestination, false
- )
- }
-}
-
-private fun detachNavHostFragment(
- fragmentManager: FragmentManager,
- navHostFragment: NavHostFragment
-) {
- fragmentManager.beginTransaction()
- .detach(navHostFragment)
- .commitNow()
-}
-
-private fun attachNavHostFragment(
- fragmentManager: FragmentManager,
- navHostFragment: NavHostFragment,
- isPrimaryNavFragment: Boolean
-) {
- fragmentManager.beginTransaction()
- .attach(navHostFragment)
- .apply {
- if (isPrimaryNavFragment) {
- setPrimaryNavigationFragment(navHostFragment)
- }
- }
- .commitNow()
-}
-
-private fun obtainNavHostFragment(
- fragmentManager: FragmentManager,
- fragmentTag: String,
- navGraphId: Int,
- containerId: Int
-): NavHostFragment {
- // If the Nav Host fragment exists, return it
- val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?
- existingFragment?.let { return it }
-
- // Otherwise, create it and return it.
- val navHostFragment = NavHostFragment.create(navGraphId)
- fragmentManager.beginTransaction()
- .add(containerId, navHostFragment, fragmentTag)
- .commitNow()
- return navHostFragment
-}
-
-private fun FragmentManager.isOnBackStack(backStackName: String): Boolean {
- val backStackCount = backStackEntryCount
- for (index in 0 until backStackCount) {
- if (getBackStackEntryAt(index).name == backStackName) {
- return true
- }
- }
- return false
-}
-
-private fun getFragmentTag(index: Int) = "bottomNavigation#$index"
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt
index 8e01e5b54..6f0cfb580 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt
@@ -25,7 +25,9 @@ import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import androidx.viewpager2.widget.ViewPager2
import com.github.livingwithhippos.unchained.R
+import com.github.livingwithhippos.unchained.utilities.extensionIconMap
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.delay
@@ -199,6 +201,7 @@ fun TextView.setFileSize(size: Long) {
R.string.file_size_format_gb,
size.toFloat() / 1024 / 1024 / 1024
)
+ // todo: shorten this
else -> this.context.getString(R.string.size_error)
}
}
@@ -315,6 +318,15 @@ fun SwipeRefreshLayout.setRefreshThemeColor(themed: Boolean) {
}
}
+@BindingAdapter("mapDrawable")
+fun ImageView.setDrawableByExtension(fileName: String) {
+ val extension = fileName.substringAfterLast(".").lowercase()
+ if (extensionIconMap.containsKey(extension))
+ this.setImageResource(extensionIconMap.getValue(extension))
+ else
+ this.setImageResource(extensionIconMap.getValue("default"))
+}
+
/**
* hides the keyboard when called on a View
*
diff --git a/app/app/src/main/res/drawable/icon_archive.xml b/app/app/src/main/res/drawable/icon_archive.xml
index e65758774..f1171cb51 100644
--- a/app/app/src/main/res/drawable/icon_archive.xml
+++ b/app/app/src/main/res/drawable/icon_archive.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/app/app/src/main/res/drawable/icon_audio.xml b/app/app/src/main/res/drawable/icon_audio.xml
new file mode 100644
index 000000000..5496fb8e0
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_audio.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_file.xml b/app/app/src/main/res/drawable/icon_file.xml
new file mode 100644
index 000000000..911877c48
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_file.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_folder.xml b/app/app/src/main/res/drawable/icon_folder.xml
new file mode 100644
index 000000000..7ea299942
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_folder.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_image.xml b/app/app/src/main/res/drawable/icon_image.xml
new file mode 100644
index 000000000..35960a0bd
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_image.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_info.xml b/app/app/src/main/res/drawable/icon_info.xml
new file mode 100644
index 000000000..7396ebcb4
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_info.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_movie.xml b/app/app/src/main/res/drawable/icon_movie.xml
new file mode 100644
index 000000000..a0e2d16de
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_movie.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_pause.xml b/app/app/src/main/res/drawable/icon_pause.xml
new file mode 100644
index 000000000..938bd7f1a
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_pause.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_sort_seeders.xml b/app/app/src/main/res/drawable/icon_sort_seeders.xml
new file mode 100644
index 000000000..3ffad4abd
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_sort_seeders.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_stop.xml b/app/app/src/main/res/drawable/icon_stop.xml
new file mode 100644
index 000000000..19bcbee79
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_stop.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/drawable/icon_subtitles.xml b/app/app/src/main/res/drawable/icon_subtitles.xml
new file mode 100644
index 000000000..f03d23d2b
--- /dev/null
+++ b/app/app/src/main/res/drawable/icon_subtitles.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/app/src/main/res/layout-land/new_download_fragment.xml b/app/app/src/main/res/layout-land/new_download_fragment.xml
index 2eab55a78..81894c6ba 100644
--- a/app/app/src/main/res/layout-land/new_download_fragment.xml
+++ b/app/app/src/main/res/layout-land/new_download_fragment.xml
@@ -82,6 +82,7 @@
android:id="@+id/tePassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:imeOptions="actionDone"
android:inputType="textPassword" />
diff --git a/app/app/src/main/res/layout-v22/fragment_torrent_details.xml b/app/app/src/main/res/layout-v22/fragment_torrent_details.xml
deleted file mode 100644
index 27ab9c3db..000000000
--- a/app/app/src/main/res/layout-v22/fragment_torrent_details.xml
+++ /dev/null
@@ -1,227 +0,0 @@
-
-
-
-
-
-