diff --git a/app/app/build.gradle b/app/app/build.gradle
index f413907d0..5f6c30eb0 100644
--- a/app/app/build.gradle
+++ b/app/app/build.gradle
@@ -41,8 +41,8 @@ android {
applicationId "com.github.livingwithhippos.unchained"
minSdk 22
targetSdk 32
- versionCode 31
- versionName "4.35.0-beta"
+ versionCode 32
+ versionName "4.40.2-beta"
// limit resources for a list of locales
// resConfigs "en", "it"
@@ -191,6 +191,7 @@ dependencies {
implementation deps.preference_ktx
implementation deps.recyclerview.recyclerview
implementation deps.recyclerview.selection
+ implementation deps.viewpager2
// datastore
// implementation deps.datastore.preferences
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
index a5581120c..cc271e0a2 100644
--- a/app/app/src/main/AndroidManifest.xml
+++ b/app/app/src/main/AndroidManifest.xml
@@ -237,6 +237,406 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ // we probably stopped and restored the app, do the same actions
+ // in the viewModel.fsmAuthenticationState.observe for these states
+
+ // unlock the bottom menu
+ enableAllBottomNavItems()
+ }
+ else -> {
+ // todo: decide if we need to check other possible values or reset the fsm to checkCredentials in these states and call startAuthenticationMachine
+ // start the authentication state machine, the first time it's going to be null
+ viewModel.startAuthenticationMachine()
+ }
+ }
// check if the app has been opened by clicking on torrents/magnet on sharing links
getIntentData()
@@ -301,11 +316,20 @@ class MainActivity : AppCompatActivity() {
}
}
}
- viewModel.setupConnectivityCheck(applicationContext)
viewModel.clearCache(applicationContext.cacheDir)
}
+ override fun onResume() {
+ super.onResume()
+ viewModel.addConnectivityCheck(applicationContext)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ viewModel.removeConnectivityCheck(applicationContext)
+ }
+
private fun downloadPlugin(link: String) {
val pluginName = link.replace("%2F", "/").split("/").last()
val manager =
@@ -385,7 +409,7 @@ class MainActivity : AppCompatActivity() {
}
}
SCHEME_HTTP, SCHEME_HTTPS -> {
- showToast("You activated the http/s scheme somehow")
+ processExternalRequestOnAuthentication(data)
}
}
}
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 5bea1f93b..95fbd695a 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
@@ -15,7 +15,7 @@ class DownloadRepository @Inject constructor(private val downloadApiHelper: Down
val downloadResponse = safeApiCall(
call = { downloadApiHelper.getDownloads("Bearer $token", offset, page, limit) },
- errorMessage = "Error Fetching User Info"
+ errorMessage = "Error Fetching Downloads list or list empty"
)
return downloadResponse ?: emptyList()
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 15ca41319..d0874939a 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
@@ -45,7 +45,7 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
token: String,
binaryTorrent: ByteArray,
host: String
- ): UploadedTorrent? {
+ ): EitherResult {
val requestBody: RequestBody = binaryTorrent.toRequestBody(
"application/octet-stream".toMediaTypeOrNull(),
@@ -53,7 +53,7 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
binaryTorrent.size
)
- val addTorrentResponse = safeApiCall(
+ val addTorrentResponse = eitherApiResult(
call = {
torrentApiHelper.addTorrent(
token = "Bearer $token",
@@ -71,8 +71,8 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
token: String,
magnet: String,
host: String
- ): UploadedTorrent? {
- val torrentResponse = safeApiCall(
+ ): EitherResult {
+ val torrentResponse = eitherApiResult(
call = {
torrentApiHelper.addMagnet(
token = "Bearer $token",
@@ -104,7 +104,7 @@ class TorrentsRepository @Inject constructor(private val torrentApiHelper: Torre
filter = filter
)
},
- errorMessage = "Error Retrieving Torrent Info"
+ errorMessage = "Error retrieving the torrents List, or list empty"
)
return torrentsResponse ?: emptyList()
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 ea99c6b4d..723ba1c3f 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
@@ -101,7 +101,7 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
suspend fun uploadContainer(
token: String,
container: ByteArray
- ): List? {
+ ): EitherResult> {
val requestBody: RequestBody = container.toRequestBody(
"application/octet-stream".toMediaTypeOrNull(),
@@ -109,7 +109,7 @@ class UnrestrictRepository @Inject constructor(private val unrestrictApiHelper:
container.size
)
- val uploadResponse = safeApiCall(
+ val uploadResponse = eitherApiResult(
call = {
unrestrictApiHelper.uploadContainer(
token = "Bearer $token",
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 3f7ead9d9..5deb1e3e1 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
@@ -28,6 +28,7 @@ import com.github.livingwithhippos.unchained.databinding.FragmentDownloadDetails
import com.github.livingwithhippos.unchained.downloaddetails.model.AlternativeDownloadAdapter
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
@@ -139,7 +140,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
activity?.baseContext?.showToast(R.string.download_removed)
// if deleted go back
activity?.onBackPressed()
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_DOWNLOAD)
+ activityViewModel.setListState(ListState.UpdateDownload)
}
)
@@ -322,6 +323,19 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener {
tryStartExternalApp(mxIntent)
}
}
+ "web_video_cast" -> {
+ val wvcIntent = createMediaIntent("com.instantbits.cast.webvideo", url)
+ tryStartExternalApp(wvcIntent)
+ }
+ "custom_player" -> {
+ val customPlayerPackage = viewModel.getCustomPlayerPreference()
+ if (customPlayerPackage.isNullOrBlank()) {
+ context?.showToast(R.string.invalid_package)
+ } else {
+ val customIntent = createMediaIntent(customPlayerPackage, url)
+ tryStartExternalApp(customIntent)
+ }
+ }
else -> {
context?.showToast(R.string.missing_default_player)
}
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 7d861e025..8aa68b902 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
@@ -92,6 +92,10 @@ class DownloadDetailsViewModel @Inject constructor(
fun getButtonVisibilityPreference(buttonKey: String, default: Boolean = true): Boolean {
return preferences.getBoolean(buttonKey, default)
}
+
+ fun getCustomPlayerPreference(): String {
+ return preferences.getString("custom_media_player", "") ?: ""
+ }
}
sealed class DownloadDetailsMessage {
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 334cb9c71..ca9cdb3e2 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
@@ -11,8 +11,9 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResultListener
-import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
@@ -20,6 +21,8 @@ import androidx.paging.PagingData
import androidx.recyclerview.selection.SelectionPredicates
import androidx.recyclerview.selection.SelectionTracker
import androidx.recyclerview.selection.StorageStrategy
+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.APIError
@@ -28,7 +31,10 @@ import com.github.livingwithhippos.unchained.data.model.DownloadItem
import com.github.livingwithhippos.unchained.data.model.EmptyBodyError
import com.github.livingwithhippos.unchained.data.model.NetworkError
import com.github.livingwithhippos.unchained.data.model.TorrentItem
+import com.github.livingwithhippos.unchained.databinding.FragmentDownloadsListBinding
import com.github.livingwithhippos.unchained.databinding.FragmentTabListsBinding
+import com.github.livingwithhippos.unchained.databinding.FragmentTorrentsListBinding
+import com.github.livingwithhippos.unchained.lists.viewmodel.ListEvent
import com.github.livingwithhippos.unchained.lists.viewmodel.ListTabsViewModel
import com.github.livingwithhippos.unchained.lists.viewmodel.ListTabsViewModel.Companion.DOWNLOADS_DELETED
import com.github.livingwithhippos.unchained.lists.viewmodel.ListTabsViewModel.Companion.DOWNLOADS_DELETED_ALL
@@ -40,16 +46,20 @@ import com.github.livingwithhippos.unchained.lists.viewmodel.ListTabsViewModel.C
import com.github.livingwithhippos.unchained.lists.viewmodel.ListTabsViewModel.Companion.TORRENT_NOT_DELETED
import com.github.livingwithhippos.unchained.statemachine.authentication.FSMAuthenticationEvent
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.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
import com.github.livingwithhippos.unchained.utilities.extension.showToast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -60,13 +70,9 @@ import kotlinx.coroutines.launch
* It is capable of showing a list of both [DownloadItem] and [TorrentItem] switched with a tab layout.
*/
@AndroidEntryPoint
-class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListListener {
+class ListsTabFragment : UnchainedFragment() {
- enum class ListState {
- UPDATE_TORRENT, UPDATE_DOWNLOAD, READY
- }
-
- private val viewModel: ListTabsViewModel by viewModels()
+ private val viewModel: ListTabsViewModel by activityViewModels()
// used to simulate a debounce effect while typing on the search bar
var queryJob: Job? = null
@@ -79,36 +85,301 @@ class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListL
val binding: FragmentTabListsBinding =
FragmentTabListsBinding.inflate(inflater, container, false)
- binding.selectedDownloads = 0
- binding.selectedTorrents = 0
- binding.selectedTab = 0
+ val listsAdapter = ListsAdapter(this)
+ binding.listPager.adapter = listsAdapter
- val downloadAdapter = DownloadListPagingAdapter(this)
- val torrentAdapter = TorrentListPagingAdapter(this)
+ binding.tabs.addOnTabSelectedListener(
+ object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab?) {
+ // to restore on resume?
+ if (tab != null)
+ viewModel.setSelectedTab(tab.position)
+ }
- binding.rvDownloadList.adapter = downloadAdapter
- binding.rvTorrentList.adapter = torrentAdapter
+ override fun onTabUnselected(tab: TabLayout.Tab?) {
+ // either do nothing or remove/add observers
+ }
- // torrent list selection tracker
- val torrentTracker: SelectionTracker = SelectionTracker.Builder(
- "torrentListSelection",
- binding.rvTorrentList,
- TorrentKeyProvider(torrentAdapter),
- DataBindingDetailsLookup(binding.rvTorrentList),
- StorageStrategy.createParcelableStorage(TorrentItem::class.java)
- ).withSelectionPredicate(
- SelectionPredicates.createSelectAnything()
- ).build()
+ override fun onTabReselected(tab: TabLayout.Tab?) {
+ // either do nothing or refresh
+ }
+ })
- torrentAdapter.tracker = torrentTracker
+ binding.fabNewDownload.setOnClickListener {
+ val action = ListsTabFragmentDirections.actionListTabsDestToNewDownloadFragment()
+ findNavController().navigate(action)
+ }
- torrentTracker.addObserver(
- object : SelectionTracker.SelectionObserver() {
- override fun onSelectionChanged() {
- super.onSelectionChanged()
- binding.selectedTorrents = torrentTracker.selection.size()
+ // an external link has been shared with the app
+ activityViewModel.externalLinkLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver { uri ->
+ val action =
+ ListsTabFragmentDirections.actionListTabsDestToNewDownloadFragment(externalUri = uri)
+ findNavController().navigate(action)
+ }
+ )
+
+ // a file has been downloaded, usually a torrent, and needs to be unrestricted
+ activityViewModel.downloadedFileLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver { fileID ->
+ val uri = requireContext().getDownloadedFileUri(fileID)
+ // no need to recheck the extension since it was checked on download
+ // if (uri?.path?.endsWith(".torrent") == true)
+ if (uri?.path != null) {
+ val action = ListsTabFragmentDirections.actionListTabsDestToNewDownloadFragment(
+ externalUri = uri
+ )
+ findNavController().navigate(action)
}
- })
+ }
+ )
+
+ // a notification has been clicked
+ activityViewModel.notificationTorrentLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver { torrentID ->
+ val action = ListsTabFragmentDirections.actionListsTabToTorrentDetails(torrentID)
+ findNavController().navigate(action)
+ }
+ )
+
+ viewModel.eventLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver { event ->
+ when (event) {
+ is ListEvent.DownloadItemClick -> {
+ val action =
+ ListsTabFragmentDirections.actionListsTabToDownloadDetails(event.item)
+ var loop = 0
+ val controller = findNavController()
+ lifecycleScope.launch {
+ while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
+ delay(100)
+ }
+ if (controller.currentDestination?.id == R.id.list_tabs_dest)
+ controller.navigate(action)
+ }
+ }
+ is ListEvent.TorrentItemClick -> {
+ when (event.item.status) {
+ "downloaded" -> {
+ if (event.item.links.size > 1) {
+ val action =
+ ListsTabFragmentDirections.actionListTabsDestToFolderListFragment2(
+ folder = null,
+ torrent = event.item,
+ linkList = null
+ )
+ findNavController().navigate(action)
+ } else
+ viewModel.downloadTorrent(event.item)
+ }
+ // open the torrent details fragment
+ else -> {
+ val action =
+ ListsTabFragmentDirections.actionListsTabToTorrentDetails(event.item.id)
+ var loop = 0
+
+ val controller = findNavController()
+ lifecycleScope.launch {
+ while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
+ delay(100)
+ }
+ if (controller.currentDestination?.id == R.id.list_tabs_dest)
+ controller.navigate(action)
+ }
+ }
+ }
+ }
+ is ListEvent.OpenTorrent -> {
+ val action =
+ ListsTabFragmentDirections.actionListsTabToTorrentDetails(event.id)
+
+ // workaround to avoid issues when the dialog still hasn't been popped from the navigation stack
+ val controller = findNavController()
+ var loop = 0
+ lifecycleScope.launch {
+ while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
+ delay(100)
+ }
+ if (controller.currentDestination?.id == R.id.list_tabs_dest)
+ controller.navigate(action)
+ }
+ }
+ is ListEvent.SetTab -> {
+
+ if (event.tab == DOWNLOADS_TAB) {
+ if (binding.listPager.currentItem == TORRENTS_TAB)
+ binding.listPager.currentItem = DOWNLOADS_TAB
+ } else {
+ if (viewModel.getSelectedTab() == DOWNLOADS_TAB)
+ binding.listPager.currentItem = TORRENTS_TAB
+ }
+ }
+ }
+
+ }
+ )
+
+
+ viewModel.errorsLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver {
+ for (error in it) {
+ when (error) {
+ is APIError -> {
+ context?.let { c ->
+ c.showToast(c.getApiErrorMessage(error.errorCode))
+ }
+ when (error.errorCode) {
+ 8 -> {
+ // bad token, try refreshing it
+ if (activityViewModel.getAuthenticationMachineState() is FSMAuthenticationState.AuthenticatedOpenToken)
+ activityViewModel.transitionAuthenticationMachine(
+ FSMAuthenticationEvent.OnExpiredOpenToken
+ )
+ context?.showToast(R.string.refreshing_token)
+ }
+ }
+ }
+ is EmptyBodyError -> {
+ }
+ is NetworkError -> {
+ context?.showToast(R.string.network_error)
+ }
+ is ApiConversionError -> {
+ context?.showToast(R.string.parsing_error)
+ }
+ }
+ }
+ }
+ )
+
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val tabLayout: TabLayout = view.findViewById(R.id.tabs)
+ val viewPager: ViewPager2 = view.findViewById(R.id.listPager)
+ TabLayoutMediator(tabLayout, viewPager) { tab, position ->
+ if (position == DOWNLOADS_TAB) {
+ tab.text = getString(R.string.downloads)
+ tab.icon = requireContext().getThemedDrawable(R.drawable.icon_cloud_done)
+ } else {
+ tab.text = getString(R.string.torrents)
+ tab.icon = requireContext().getThemedDrawable(R.drawable.icon_torrent_logo)
+ }
+ }.attach()
+
+ 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))
+ .setMessage(
+ if (selectedTab == DOWNLOADS_TAB)
+ getString(R.string.delete_all_downloads_message)
+ else
+ getString(R.string.delete_all_torrents_message)
+ )
+ .setNegativeButton(getString(R.string.decline)) { _, _ ->
+ }
+ .setPositiveButton(getString(R.string.accept)) { _, _ ->
+ if (selectedTab == DOWNLOADS_TAB)
+ viewModel.deleteAllDownloads()
+ else
+ viewModel.deleteAllTorrents()
+ }
+ .show()
+ }
+}
+
+class ListsAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
+
+ override fun getItemCount(): Int = 2
+
+ override fun createFragment(position: Int): Fragment {
+ return if (position == DOWNLOADS_TAB) {
+ DownloadsListFragment()
+ } else {
+ TorrentsListFragment()
+ }
+ }
+}
+
+@AndroidEntryPoint
+class DownloadsListFragment : UnchainedFragment(), DownloadListListener {
+
+ private val viewModel: ListTabsViewModel by activityViewModels()
+
+ // used to simulate a debounce effect while typing on the search bar
+ var queryJob: Job? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = FragmentDownloadsListBinding.inflate(inflater, container, false)
+
+ binding.selectedDownloads = 0
+
+ val downloadAdapter = DownloadListPagingAdapter(this)
+ binding.rvDownloadList.adapter = downloadAdapter
// download list selection tracker
val downloadTracker: SelectionTracker = SelectionTracker.Builder(
@@ -134,104 +405,71 @@ class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListL
// listener for selection buttons
binding.listener = object : SelectedItemsButtonsListener {
override fun deleteSelectedItems() {
- if (binding.tabs.selectedTabPosition == TAB_DOWNLOADS) {
- if (downloadTracker.selection.toList().isNotEmpty())
- viewModel.deleteDownloads(downloadTracker.selection.toList())
- else
- context?.showToast(R.string.select_one_item)
- } else {
- if (torrentTracker.selection.toList().isNotEmpty())
- viewModel.deleteTorrents(torrentTracker.selection.toList())
- else
- context?.showToast(R.string.select_one_item)
- }
+ if (downloadTracker.selection.toList().isNotEmpty())
+ viewModel.deleteDownloads(downloadTracker.selection.toList())
+ else
+ context?.showToast(R.string.select_one_item)
}
override fun shareSelectedItems() {
- if (binding.tabs.selectedTabPosition == TAB_DOWNLOADS) {
-
- if (downloadTracker.selection.toList().isNotEmpty()) {
- val shareIntent = Intent(Intent.ACTION_SEND)
- shareIntent.type = "text/plain"
- val shareLinks =
- downloadTracker.selection.joinToString("\n") { it.download }
- shareIntent.putExtra(Intent.EXTRA_TEXT, shareLinks)
- startActivity(
- Intent.createChooser(
- shareIntent,
- getString(R.string.share_with)
- )
+ if (downloadTracker.selection.toList().isNotEmpty()) {
+ val shareIntent = Intent(Intent.ACTION_SEND)
+ shareIntent.type = "text/plain"
+ val shareLinks =
+ downloadTracker.selection.joinToString("\n") { it.download }
+ shareIntent.putExtra(Intent.EXTRA_TEXT, shareLinks)
+ startActivity(
+ Intent.createChooser(
+ shareIntent,
+ getString(R.string.share_with)
)
- } else
- context?.showToast(R.string.select_one_item)
- }
+ )
+ } else
+ context?.showToast(R.string.select_one_item)
}
override fun downloadSelectedItems() {
- if (binding.tabs.selectedTabPosition == TAB_DOWNLOADS) {
-
- 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),
- )
- when (queuedDownload) {
- is EitherResult.Failure -> {
- context?.showToast(
- getString(
- R.string.download_not_started_format,
- item.filename
- )
+ 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),
+ )
+ when (queuedDownload) {
+ is EitherResult.Failure -> {
+ context?.showToast(
+ getString(
+ R.string.download_not_started_format,
+ item.filename
)
- }
- is EitherResult.Success -> {
- downloadStarted = true
- }
+ )
+ }
+ is EitherResult.Success -> {
+ downloadStarted = true
}
}
- if (downloadStarted)
- context?.showToast(R.string.download_started)
- } else
- context?.showToast(R.string.select_one_item)
- } else {
- if (torrentTracker.selection.toList().isNotEmpty()) {
- viewModel.downloadItems(torrentTracker.selection.toList())
- } else
- context?.showToast(R.string.select_one_item)
- }
+ }
+ if (downloadStarted)
+ context?.showToast(R.string.download_started)
+ } else
+ context?.showToast(R.string.select_one_item)
}
}
binding.cbSelectAll.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
- if (binding.tabs.selectedTabPosition == TAB_DOWNLOADS) {
- downloadTracker.setItemsSelected(downloadAdapter.snapshot().items, true)
- } else {
- torrentTracker.setItemsSelected(torrentAdapter.snapshot().items, true)
- }
+ downloadTracker.setItemsSelected(downloadAdapter.snapshot().items, true)
} else {
- if (binding.tabs.selectedTabPosition == TAB_DOWNLOADS) {
- downloadTracker.clearSelection()
- } else {
- torrentTracker.clearSelection()
- }
+ downloadTracker.clearSelection()
}
}
binding.srLayout.setOnRefreshListener {
- when (binding.tabs.selectedTabPosition) {
- TAB_DOWNLOADS -> {
- downloadAdapter.refresh()
- }
- TAB_TORRENTS -> {
- torrentAdapter.refresh()
- }
- }
+ downloadAdapter.refresh()
}
// observers created to be easily added and removed. Pass the retrieved list to the adapter and removes the loading icon from the swipe layout
@@ -252,6 +490,182 @@ class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListL
}
}
+ // checks the authentication state. Needed to avoid automatic API calls before the authentication process is finished
+ activityViewModel.fsmAuthenticationState.observe(
+ viewLifecycleOwner
+ ) {
+ when (it?.peekContent()) {
+ FSMAuthenticationState.AuthenticatedOpenToken, FSMAuthenticationState.AuthenticatedPrivateToken -> {
+ // register observers if not already registered
+ if (!viewModel.downloadsLiveData.hasActiveObservers())
+ viewModel.downloadsLiveData.observe(
+ viewLifecycleOwner,
+ downloadObserver
+ )
+ }
+ else -> {
+ }
+ }
+ }
+
+ viewModel.downloadItemLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver { links ->
+ // todo: if it gets emptied null/empty should be processed too
+ if (!links.isNullOrEmpty()) {
+ // simulate list refresh
+ binding.srLayout.isRefreshing = true
+ // refresh items, when returned they'll stop the animation
+ downloadAdapter.refresh()
+
+ viewModel.postEventNotice(ListEvent.SetTab(DOWNLOADS_TAB))
+ }
+ }
+ )
+
+ activityViewModel.listStateLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver {
+ when (it) {
+ ListState.UpdateDownload -> {
+ lifecycleScope.launch {
+ delay(300L)
+ downloadAdapter.refresh()
+ lifecycleScope.launch {
+ binding.rvDownloadList.delayedScrolling(requireContext())
+ }
+ }
+ }
+ else -> {}
+ }
+ }
+ )
+
+ setFragmentResultListener("downloadActionKey") { _, bundle ->
+ bundle.getString("deletedDownloadKey")?.let {
+ viewModel.deleteDownload(it)
+ }
+ bundle.getParcelable("openedDownloadItem")?.let {
+ onClick(it)
+ }
+ }
+
+ viewModel.deletedDownloadLiveData.observe(
+ viewLifecycleOwner,
+ EventObserver {
+ when (it) {
+ DOWNLOAD_NOT_DELETED -> {
+ }
+ DOWNLOAD_DELETED -> {
+ context?.showToast(R.string.download_removed)
+ downloadAdapter.refresh()
+ }
+ DOWNLOADS_DELETED -> {
+ context?.showToast(R.string.downloads_removed)
+ downloadAdapter.refresh()
+ }
+ DOWNLOADS_DELETED_ALL -> {
+ context?.showToast(R.string.downloads_removed)
+ lifecycleScope.launch {
+ // if we don't refresh the cached copy of the last result will be restored on the first list redraw
+ downloadAdapter.refresh()
+ downloadAdapter.submitData(PagingData.empty())
+ }
+ }
+ 0 -> {
+ context?.showToast(R.string.removing_downloads)
+ }
+ else -> {
+ downloadAdapter.refresh()
+ }
+ }
+ }
+ )
+
+ // starts the Transformations.switchMap(queryLiveData) which otherwise won't trigger the Paging request
+ viewModel.setListFilter("")
+
+ return binding.root
+ }
+
+ override fun onClick(item: DownloadItem) {
+ viewModel.postEventNotice(ListEvent.DownloadItemClick(item))
+ }
+}
+
+@AndroidEntryPoint
+class TorrentsListFragment : UnchainedFragment(), TorrentListListener {
+
+ private val viewModel: ListTabsViewModel by activityViewModels()
+
+ // used to simulate a debounce effect while typing on the search bar
+ var queryJob: Job? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = FragmentTorrentsListBinding.inflate(inflater, container, false)
+
+ val torrentAdapter = TorrentListPagingAdapter(this)
+ binding.rvTorrentList.adapter = torrentAdapter
+
+ // torrent list selection tracker
+ binding.selectedTorrents = 0
+ val torrentTracker: SelectionTracker = SelectionTracker.Builder(
+ "torrentListSelection",
+ binding.rvTorrentList,
+ TorrentKeyProvider(torrentAdapter),
+ DataBindingDetailsLookup(binding.rvTorrentList),
+ StorageStrategy.createParcelableStorage(TorrentItem::class.java)
+ ).withSelectionPredicate(
+ SelectionPredicates.createSelectAnything()
+ ).build()
+
+ torrentAdapter.tracker = torrentTracker
+
+ torrentTracker.addObserver(
+ object : SelectionTracker.SelectionObserver() {
+ override fun onSelectionChanged() {
+ super.onSelectionChanged()
+ binding.selectedTorrents = torrentTracker.selection.size()
+ }
+ })
+
+ // listener for selection buttons
+ binding.listener = object : SelectedItemsButtonsListener {
+ override fun deleteSelectedItems() {
+ if (torrentTracker.selection.toList().isNotEmpty())
+ viewModel.deleteTorrents(torrentTracker.selection.toList())
+ else
+ context?.showToast(R.string.select_one_item)
+ }
+
+ override fun shareSelectedItems() {
+ // do nothing for torrents
+ }
+
+ override fun downloadSelectedItems() {
+ if (torrentTracker.selection.toList().isNotEmpty()) {
+ viewModel.downloadItems(torrentTracker.selection.toList())
+ } else
+ context?.showToast(R.string.select_one_item)
+ }
+ }
+
+ binding.cbSelectAll.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ torrentTracker.setItemsSelected(torrentAdapter.snapshot().items, true)
+ } else {
+ torrentTracker.clearSelection()
+ }
+ }
+
+ binding.srLayout.setOnRefreshListener {
+ torrentAdapter.refresh()
+ }
+
val torrentObserver = Observer> {
lifecycleScope.launch {
torrentAdapter.submitData(it)
@@ -267,89 +681,19 @@ class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListL
}
// checks the authentication state. Needed to avoid automatic API calls before the authentication process is finished
- activityViewModel.fsmAuthenticationState.observe(viewLifecycleOwner, {
- if (it != null) {
- when (it.peekContent()) {
- FSMAuthenticationState.AuthenticatedOpenToken, FSMAuthenticationState.AuthenticatedPrivateToken -> {
- // register observers if not already registered
- if (!viewModel.downloadsLiveData.hasActiveObservers())
- viewModel.downloadsLiveData.observe(
- viewLifecycleOwner,
- downloadObserver
- )
-
- if (!viewModel.torrentsLiveData.hasActiveObservers())
- viewModel.torrentsLiveData.observe(viewLifecycleOwner, torrentObserver)
- }
- else -> {
- }
- }
- }
- }
- )
-
- binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
-
- override fun onTabSelected(tab: TabLayout.Tab?) {
- tab?.let {
-
- binding.selectedTab = it.position
-
- when (it.position) {
- TAB_DOWNLOADS -> {
- viewModel.setSelectedTab(TAB_DOWNLOADS)
- binding.rvTorrentList.visibility = View.GONE
- binding.rvDownloadList.visibility = View.VISIBLE
- if (!viewModel.downloadsLiveData.hasActiveObservers())
- viewModel.downloadsLiveData.observe(
- viewLifecycleOwner,
- downloadObserver
- )
- }
- TAB_TORRENTS -> {
- viewModel.setSelectedTab(TAB_TORRENTS)
- binding.rvTorrentList.visibility = View.VISIBLE
- binding.rvDownloadList.visibility = View.GONE
- if (!viewModel.torrentsLiveData.hasActiveObservers())
- viewModel.torrentsLiveData.observe(
- viewLifecycleOwner,
- torrentObserver
- )
- }
- }
- }
- }
-
- override fun onTabReselected(tab: TabLayout.Tab?) {
- // either do nothing or refresh
- }
-
- override fun onTabUnselected(tab: TabLayout.Tab?) {
- // remove observer
- when (tab?.position) {
- TAB_DOWNLOADS -> {
- viewModel.downloadsLiveData.removeObserver(downloadObserver)
- }
- TAB_TORRENTS -> {
- viewModel.torrentsLiveData.removeObserver(torrentObserver)
- }
+ activityViewModel.fsmAuthenticationState.observe(
+ viewLifecycleOwner
+ ) {
+ when (it?.peekContent()) {
+ FSMAuthenticationState.AuthenticatedOpenToken, FSMAuthenticationState.AuthenticatedPrivateToken -> {
+ // register observers if not already registered
+ if (!viewModel.torrentsLiveData.hasActiveObservers())
+ viewModel.torrentsLiveData.observe(viewLifecycleOwner, torrentObserver)
}
- }
- })
-
- viewModel.downloadItemLiveData.observe(
- viewLifecycleOwner,
- EventObserver { links ->
- if (!links.isNullOrEmpty()) {
- // switch to download tab
- binding.tabs.getTabAt(TAB_DOWNLOADS)?.select()
- // simulate list refresh
- binding.srLayout.isRefreshing = true
- // refresh items, when returned they'll stop the animation
- downloadAdapter.refresh()
+ else -> {
}
}
- )
+ }
viewModel.deletedTorrentLiveData.observe(
viewLifecycleOwner,
@@ -387,16 +731,7 @@ class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListL
viewLifecycleOwner,
EventObserver {
when (it) {
- ListState.UPDATE_DOWNLOAD -> {
- lifecycleScope.launch {
- delay(300L)
- downloadAdapter.refresh()
- lifecycleScope.launch {
- binding.rvDownloadList.delayedScrolling(requireContext())
- }
- }
- }
- ListState.UPDATE_TORRENT -> {
+ ListState.UpdateTorrent -> {
lifecycleScope.launch {
delay(300L)
torrentAdapter.refresh()
@@ -405,271 +740,38 @@ class ListsTabFragment : UnchainedFragment(), DownloadListListener, TorrentListL
}
}
}
- ListState.READY -> {
+ else -> {
}
}
}
)
- setFragmentResultListener("downloadActionKey") { _, bundle ->
- bundle.getString("deletedDownloadKey")?.let {
- viewModel.deleteDownload(it)
- }
- bundle.getParcelable("openedDownloadItem")?.let {
- onClick(it)
- }
- }
-
setFragmentResultListener("torrentActionKey") { _, bundle ->
bundle.getString("deletedTorrentKey")?.let {
viewModel.deleteTorrent(it)
}
bundle.getString("openedTorrentItem")?.let {
- val action = ListsTabFragmentDirections.actionListsTabToTorrentDetails(it)
-
- // workaround to avoid issues when the dialog still hasn't been popped from the navigation stack
- val controller = findNavController()
- var loop = 0
- lifecycleScope.launch {
- while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
- delay(100)
- }
- if (controller.currentDestination?.id == R.id.list_tabs_dest)
- controller.navigate(action)
- }
+ viewModel.postEventNotice(ListEvent.OpenTorrent(it))
}
bundle.getParcelable("downloadedTorrentItem")?.let {
onClick(it)
}
}
- viewModel.deletedDownloadLiveData.observe(
- viewLifecycleOwner,
- EventObserver {
- when (it) {
- DOWNLOAD_NOT_DELETED -> {
- }
- DOWNLOAD_DELETED -> {
- context?.showToast(R.string.download_removed)
- downloadAdapter.refresh()
- }
- DOWNLOADS_DELETED -> {
- context?.showToast(R.string.downloads_removed)
- downloadAdapter.refresh()
- }
- DOWNLOADS_DELETED_ALL -> {
- context?.showToast(R.string.downloads_removed)
- lifecycleScope.launch {
- // if we don't refresh the cached copy of the last result will be restored on the first list redraw
- downloadAdapter.refresh()
- downloadAdapter.submitData(PagingData.empty())
- }
- }
- 0 -> {
- context?.showToast(R.string.removing_downloads)
- }
- else -> {
- downloadAdapter.refresh()
- }
- }
- }
- )
-
- viewModel.errorsLiveData.observe(
- viewLifecycleOwner,
- EventObserver {
- for (error in it) {
- when (error) {
- is APIError -> {
- context?.let { c ->
- c.showToast(c.getApiErrorMessage(error.errorCode))
- }
- when (error.errorCode) {
- 8 -> {
- // bad token, try refreshing it
- if (activityViewModel.getAuthenticationMachineState() is FSMAuthenticationState.AuthenticatedOpenToken)
- activityViewModel.transitionAuthenticationMachine(
- FSMAuthenticationEvent.OnExpiredOpenToken
- )
- context?.showToast(R.string.refreshing_token)
- }
- }
- }
- is EmptyBodyError -> {
- }
- is NetworkError -> {
- context?.showToast(R.string.network_error)
- }
- is ApiConversionError -> {
- context?.showToast(R.string.parsing_error)
- }
- }
- }
- }
- )
-
- binding.fabNewDownload.setOnClickListener {
- val action = ListsTabFragmentDirections.actionListTabsDestToNewDownloadFragment()
- findNavController().navigate(action)
- }
-
- // starts the Transformations.switchMap(queryLiveData) which otherwise won't trigger the Paging request
- viewModel.setListFilter("")
-
- binding.tabs.getTabAt(viewModel.getSelectedTab())?.select()
-
- // an external link has been shared with the app
- activityViewModel.externalLinkLiveData.observe(
- viewLifecycleOwner,
- EventObserver { uri ->
- val action =
- ListsTabFragmentDirections.actionListTabsDestToNewDownloadFragment(externalUri = uri)
- findNavController().navigate(action)
- }
- )
- // a file has been downloaded, usually a torrent, and needs to be unrestricted
- activityViewModel.downloadedFileLiveData.observe(
- viewLifecycleOwner,
- EventObserver { fileID ->
- val uri = requireContext().getDownloadedFileUri(fileID)
- // no need to recheck the extension since it was checked on download
- // if (uri?.path?.endsWith(".torrent") == true)
- if (uri?.path != null) {
- val action = ListsTabFragmentDirections.actionListTabsDestToNewDownloadFragment(
- externalUri = uri
- )
- findNavController().navigate(action)
- }
- }
- )
-
- // a notification has been clicked
- activityViewModel.notificationTorrentLiveData.observe(
- viewLifecycleOwner,
- EventObserver { torrentID ->
- val action = ListsTabFragmentDirections.actionListsTabToTorrentDetails(torrentID)
- findNavController().navigate(action)
- }
- )
-
return binding.root
}
- // 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 -> {
- showDeleteAllDialog(viewModel.getSelectedTab())
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun showDeleteAllDialog(selectedTab: Int) {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(getString(R.string.delete_all))
- .setMessage(
- if (selectedTab == TAB_DOWNLOADS)
- getString(R.string.delete_all_downloads_message)
- else
- getString(R.string.delete_all_torrents_message)
- )
- .setNegativeButton(getString(R.string.decline)) { _, _ ->
- }
- .setPositiveButton(getString(R.string.accept)) { _, _ ->
- if (selectedTab == TAB_DOWNLOADS)
- viewModel.deleteAllDownloads()
- else
- viewModel.deleteAllTorrents()
- }
- .show()
- }
-
- override fun onClick(item: DownloadItem) {
- val action = ListsTabFragmentDirections.actionListsTabToDownloadDetails(item)
- var loop = 0
- val controller = findNavController()
- lifecycleScope.launch {
- while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
- delay(100)
- }
- if (controller.currentDestination?.id == R.id.list_tabs_dest)
- controller.navigate(action)
- }
+ override fun onClick(item: TorrentItem) {
+ viewModel.postEventNotice(ListEvent.TorrentItemClick(item))
}
- override fun onClick(item: TorrentItem) {
- when (item.status) {
- "downloaded" -> {
- if (item.links.size > 1) {
- val action =
- ListsTabFragmentDirections.actionListTabsDestToFolderListFragment2(
- folder = null,
- torrent = item,
- linkList = null
- )
- findNavController().navigate(action)
- } else
- viewModel.downloadTorrent(item)
- }
- // open the torrent details fragment
- else -> {
- val action = ListsTabFragmentDirections.actionListsTabToTorrentDetails(item.id)
- var loop = 0
+}
- val controller = findNavController()
- lifecycleScope.launch {
- while (loop++ < 20 && controller.currentDestination?.id != R.id.list_tabs_dest) {
- delay(100)
- }
- if (controller.currentDestination?.id == R.id.list_tabs_dest)
- controller.navigate(action)
- }
- }
- }
- }
- companion object {
- const val TAB_DOWNLOADS = 0
- const val TAB_TORRENTS = 1
- }
+sealed class ListState {
+ object UpdateTorrent : ListState()
+ object UpdateDownload : ListState()
+ object Ready : ListState()
}
interface SelectedItemsButtonsListener {
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 4ff6b1a37..840c6386e 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
@@ -20,7 +20,7 @@ import com.github.livingwithhippos.unchained.data.repository.DownloadRepository
import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository
import com.github.livingwithhippos.unchained.data.repository.UnrestrictRepository
import com.github.livingwithhippos.unchained.lists.model.DownloadPagingSource
-import com.github.livingwithhippos.unchained.lists.view.ListsTabFragment.Companion.TAB_DOWNLOADS
+import com.github.livingwithhippos.unchained.utilities.DOWNLOADS_TAB
import com.github.livingwithhippos.unchained.utilities.EitherResult
import com.github.livingwithhippos.unchained.utilities.Event
import com.github.livingwithhippos.unchained.utilities.postEvent
@@ -66,6 +66,8 @@ class ListTabsViewModel @Inject constructor(
val deletedTorrentLiveData = MutableLiveData>()
val deletedDownloadLiveData = MutableLiveData>()
+ val eventLiveData = MutableLiveData>()
+
fun downloadTorrent(torrent: TorrentItem) {
viewModelScope.launch {
val token = protoStore.getCredentials().accessToken
@@ -130,7 +132,7 @@ class ListTabsViewModel @Inject constructor(
}
fun getSelectedTab(): Int {
- return savedStateHandle.get(KEY_SELECTED_TAB) ?: TAB_DOWNLOADS
+ return savedStateHandle.get(KEY_SELECTED_TAB) ?: DOWNLOADS_TAB
}
fun setListFilter(query: String?) {
@@ -212,6 +214,10 @@ class ListTabsViewModel @Inject constructor(
}
}
+ fun postEventNotice(event: ListEvent) {
+ eventLiveData.postEvent(event)
+ }
+
companion object {
const val KEY_SELECTED_TAB = "selected_tab_key"
const val TORRENT_DELETED = -1
@@ -224,3 +230,10 @@ class ListTabsViewModel @Inject constructor(
const val DOWNLOAD_NOT_DELETED = -4
}
}
+
+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 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 4ebe1ba82..bd66457b3 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
@@ -23,6 +23,7 @@ import com.github.livingwithhippos.unchained.data.model.EmptyBodyError
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
@@ -43,6 +44,7 @@ import com.github.livingwithhippos.unchained.utilities.extension.isMagnet
import com.github.livingwithhippos.unchained.utilities.extension.isTorrent
import com.github.livingwithhippos.unchained.utilities.extension.isWebUrl
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
@@ -83,7 +85,7 @@ class NewDownloadFragment : UnchainedFragment() {
viewLifecycleOwner,
EventObserver { linkDetails ->
// new download item, alert the list fragment that it needs updating
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_DOWNLOAD)
+ activityViewModel.setListState(ListState.UpdateDownload)
val action =
NewDownloadFragmentDirections.actionUnrestrictDownloadToDetailsFragment(
linkDetails
@@ -96,7 +98,7 @@ class NewDownloadFragment : UnchainedFragment() {
viewLifecycleOwner,
EventObserver { folder ->
// new folder list, alert the list fragment that it needs updating
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_DOWNLOAD)
+ activityViewModel.setListState(ListState.UpdateDownload)
val action =
NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment(
folder = folder,
@@ -111,7 +113,7 @@ class NewDownloadFragment : UnchainedFragment() {
viewLifecycleOwner,
EventObserver { torrent ->
// new torrent item, alert the list fragment that it needs updating
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_TORRENT)
+ activityViewModel.setListState(ListState.UpdateTorrent)
val action =
NewDownloadFragmentDirections.actionNewDownloadDestToTorrentDetailsFragment(
torrent.id
@@ -126,7 +128,7 @@ class NewDownloadFragment : UnchainedFragment() {
when (link) {
is Link.Container -> {
// new container, alert the list fragment that it needs updating
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_DOWNLOAD)
+ activityViewModel.setListState(ListState.UpdateDownload)
val action =
NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment(
linkList = link.links.toTypedArray(),
@@ -185,7 +187,7 @@ class NewDownloadFragment : UnchainedFragment() {
else
Timber.e("Asked for a refresh while in a wrong state: ${activityViewModel.getAuthenticationMachineState()}")
}
- in 9..15 -> {
+ in 10..15 -> {
viewModel.postMessage(errorMessage)
when (activityViewModel.getAuthenticationMachineState()) {
FSMAuthenticationState.AuthenticatedOpenToken, FSMAuthenticationState.AuthenticatedPrivateToken, FSMAuthenticationState.RefreshingOpenToken -> {
@@ -198,6 +200,11 @@ class NewDownloadFragment : UnchainedFragment() {
}
}
}
+ 9 -> {
+ // todo: check if permission denied (code 9) is related only to asking for magnet without premium or other stuff too
+ // we use this because permission denied is not clear
+ viewModel.postMessage(getString(R.string.premium_needed))
+ }
else -> {
viewModel.postMessage(errorMessage)
}
@@ -216,13 +223,20 @@ class NewDownloadFragment : UnchainedFragment() {
@SuppressLint("ShowToast")
val currentToast: Toast = Toast.makeText(requireContext(), "", Toast.LENGTH_SHORT)
+ var lastToastTime = System.currentTimeMillis()
viewModel.toastLiveData.observe(
viewLifecycleOwner,
EventObserver {
- currentToast.cancel()
- currentToast.setText(it)
- currentToast.show()
+ lifecycleScope.launch {
+ currentToast.cancel()
+ // if we call this too soon between toasts we'll miss some
+ if (System.currentTimeMillis() - lastToastTime < 1000L)
+ delay(1000)
+ currentToast.setText(it)
+ currentToast.show()
+ lastToastTime = System.currentTimeMillis()
+ }
}
)
}
@@ -288,7 +302,7 @@ class NewDownloadFragment : UnchainedFragment() {
enableButtons(binding, false)
// new folder list, alert the list fragment that it needs updating
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_DOWNLOAD)
+ activityViewModel.setListState(ListState.UpdateDownload)
val action =
NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment(
folder = null,
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 91ccfa20a..88403ba86 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
@@ -67,11 +67,14 @@ class NewDownloadViewModel @Inject constructor(
fun uploadContainer(container: ByteArray) {
viewModelScope.launch {
val token = getToken()
- val fileList = unrestrictRepository.uploadContainer(token, container)
- if (fileList != null)
- containerLiveData.postEvent(Link.Container(fileList))
- else
- containerLiveData.postEvent(Link.RetrievalError)
+ when (val fileList = unrestrictRepository.uploadContainer(token, container)) {
+ is EitherResult.Failure -> {
+ networkExceptionLiveData.postEvent(fileList.failure)
+ }
+ is EitherResult.Success -> {
+ containerLiveData.postEvent(Link.Container(fileList.success))
+ }
+ }
}
}
@@ -95,11 +98,13 @@ class NewDownloadViewModel @Inject constructor(
} else {
val addedMagnet =
torrentsRepository.addMagnet(token, magnet, availableHosts.first().host)
- if (addedMagnet != null) {
- // todo: add custom selection of files, this queues all the files
- // todo: add checks for already chosen torrent/magnet (if possible), otherwise we get multiple downloads
- // todo: get file info and check if it has already been downloaded before doing a select files
- torrentLiveData.postEvent(addedMagnet)
+ when (addedMagnet) {
+ is EitherResult.Failure -> {
+ networkExceptionLiveData.postEvent(addedMagnet.failure)
+ }
+ is EitherResult.Success -> {
+ torrentLiveData.postEvent(addedMagnet.success)
+ }
}
}
}
@@ -114,9 +119,14 @@ class NewDownloadViewModel @Inject constructor(
} else {
val uploadedTorrent =
torrentsRepository.addTorrent(token, binaryTorrent, availableHosts.first().host)
- if (uploadedTorrent != null) {
- // todo: add checks for already chosen torrent/magnet (if possible), otherwise we get multiple downloads
- torrentLiveData.postEvent(uploadedTorrent)
+ 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)
+ }
}
}
}
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 a37c372f9..2d1c6c3e9 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
@@ -26,5 +26,6 @@ interface LinkItemListener {
data class LinkItem(
val type: String,
+ val name: String,
val link: String
)
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 59d0b5048..b4eb3f0f2 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
@@ -156,6 +156,7 @@ class SearchFragment : UnchainedFragment(), SearchItemListener {
pluginPickerView?.setOnItemClickListener { _, _, position, _ ->
val selection: String? = pluginAdapter.getItem(position)
if (selection != null) {
+ binding.pluginPicker.hideKeyboard()
setupCategory(categoryPickerView, plugins.first { it.name == selection })
viewModel.setLastSelectedPlugin(plugins.first { it.name == selection }.name)
}
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 61022562b..551d1907e 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
@@ -45,13 +45,13 @@ class SearchItemFragment : UnchainedFragment(), LinkItemListener {
val links = mutableListOf()
item.magnets.forEach {
- links.add(LinkItem(getString(R.string.magnet), it))
+ links.add(LinkItem(getString(R.string.magnet), it.substringBefore("&"), it))
}
item.torrents.forEach {
- links.add(LinkItem(getString(R.string.torrent), it))
+ links.add(LinkItem(getString(R.string.torrent), it, it))
}
item.hosting.forEach {
- links.add(LinkItem(getString(R.string.hoster), it))
+ links.add(LinkItem(getString(R.string.hoster), it, it))
}
adapter.submitList(links)
}
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 32837009a..49827cfa9 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
@@ -31,6 +31,7 @@ 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.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.plugins.model.Plugin
import com.github.livingwithhippos.unchained.statemachine.authentication.CurrentFSMAuthentication
@@ -89,7 +90,7 @@ class MainActivityViewModel @Inject constructor(
val notificationTorrentLiveData = MutableLiveData>()
- val listStateLiveData = MutableLiveData>()
+ val listStateLiveData = MutableLiveData>()
val connectivityLiveData = MutableLiveData()
// val currentNetworkLiveData = MutableLiveData()
@@ -127,7 +128,7 @@ class MainActivityViewModel @Inject constructor(
on {
transitionTo(
FSMAuthenticationState.AuthenticatedOpenToken,
- FSMAuthenticationSideEffect.PostAuthenticatedPrivate
+ FSMAuthenticationSideEffect.PostAuthenticatedOpen
)
}
on {
@@ -139,7 +140,7 @@ class MainActivityViewModel @Inject constructor(
on {
transitionTo(
FSMAuthenticationState.AuthenticatedPrivateToken,
- FSMAuthenticationSideEffect.PostAuthenticatedOpen
+ FSMAuthenticationSideEffect.PostAuthenticatedPrivate
)
}
on {
@@ -647,7 +648,7 @@ class MainActivityViewModel @Inject constructor(
}
// todo: move this stuff to a shared navigationViewModel
- fun setListState(state: ListsTabFragment.ListState) {
+ fun setListState(state: ListState) {
listStateLiveData.postEvent(state)
}
@@ -810,7 +811,7 @@ class MainActivityViewModel @Inject constructor(
}
}
- fun setupConnectivityCheck(context: Context) {
+ fun addConnectivityCheck(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -820,6 +821,14 @@ class MainActivityViewModel @Inject constructor(
}
}
+ fun removeConnectivityCheck(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ }
+ }
+
private fun checkConnectivity(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
val connectivityManager =
@@ -1053,6 +1062,7 @@ class MainActivityViewModel @Inject constructor(
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"
}
}
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 672951d3b..aad6b2d56 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
@@ -22,6 +22,7 @@ import com.github.livingwithhippos.unchained.data.model.EmptyBodyError
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.viewmodel.TorrentDetailsViewModel
import com.github.livingwithhippos.unchained.utilities.EventObserver
@@ -116,7 +117,7 @@ class TorrentDetailsFragment : UnchainedFragment(), TorrentDetailsListener {
activity?.baseContext?.showToast(R.string.torrent_removed)
// if deleted go back
activity?.onBackPressed()
- activityViewModel.setListState(ListsTabFragment.ListState.UPDATE_TORRENT)
+ activityViewModel.setListState(ListState.UpdateTorrent)
}
)
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 c9cbe9860..07898bceb 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
@@ -88,3 +88,6 @@ val loadingStatusList = listOf(
"compressing",
"uploading"
)
+
+const val DOWNLOADS_TAB = 0
+const val TORRENTS_TAB = 1
diff --git a/app/app/src/main/res/layout/fragment_downloads_list.xml b/app/app/src/main/res/layout/fragment_downloads_list.xml
new file mode 100644
index 000000000..a3b24d9b6
--- /dev/null
+++ b/app/app/src/main/res/layout/fragment_downloads_list.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/fragment_tab_lists.xml b/app/app/src/main/res/layout/fragment_tab_lists.xml
index 4828fe8a0..bc0aa34a5 100644
--- a/app/app/src/main/res/layout/fragment_tab_lists.xml
+++ b/app/app/src/main/res/layout/fragment_tab_lists.xml
@@ -7,22 +7,11 @@
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/item_list_link.xml b/app/app/src/main/res/layout/item_list_link.xml
index 10fb44fbe..7c76bae99 100644
--- a/app/app/src/main/res/layout/item_list_link.xml
+++ b/app/app/src/main/res/layout/item_list_link.xml
@@ -54,8 +54,8 @@
style="@style/TextAppearance.UnchainedTheme.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:maxLines="7"
- android:text="@{item.link, default=`https://verylonglinkbecauseihavetotestthislinkinmylayoutpreview.com/randomfileletterstomakeitevenmorelong:234gyu234gyu`}"
+ android:maxLines="4"
+ android:text="@{item.name, default=`https://verylonglinkbecauseihavetotestthislinkinmylayoutpreview.com/randomfileletterstomakeitevenmorelong:234gyu234gyu`}"
app:layout_constraintStart_toStartOf="@id/type"
app:layout_constraintTop_toBottomOf="@id/type"
app:layout_constraintBottom_toBottomOf="parent"
diff --git a/app/app/src/main/res/menu/lists_bar.xml b/app/app/src/main/res/menu/lists_bar.xml
index 693d1d532..929d0ab45 100644
--- a/app/app/src/main/res/menu/lists_bar.xml
+++ b/app/app/src/main/res/menu/lists_bar.xml
@@ -12,10 +12,17 @@
app:actionViewClass="androidx.appcompat.widget.SearchView" />
+
+
\ No newline at end of file
diff --git a/app/app/src/main/res/values-fr/strings.xml b/app/app/src/main/res/values-fr/strings.xml
index f391ff916..71c1f3a0f 100644
--- a/app/app/src/main/res/values-fr/strings.xml
+++ b/app/app/src/main/res/values-fr/strings.xml
@@ -150,8 +150,7 @@