package org.schabi.newpipe import android.annotation.SuppressLint import android.app.IntentService import android.content.Context import android.content.DialogInterface import android.content.DialogInterface.OnShowListener import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.text.TextUtils import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.Button import android.widget.RadioButton import android.widget.RadioGroup import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.math.MathUtils import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.isAtLeast import androidx.lifecycle.Lifecycle.addObserver import androidx.lifecycle.Lifecycle.currentState import androidx.lifecycle.Lifecycle.removeObserver import androidx.lifecycle.LifecycleOwner import androidx.preference.PreferenceManager import icepick.Icepick import icepick.State import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.SingleEmitter import io.reactivex.rxjava3.core.SingleOnSubscribe import io.reactivex.rxjava3.core.SingleTransformer import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.functions.Cancellable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.RouterActivity.FetcherService import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.databinding.ListRadioIconItemBinding import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.download.LoadingDialog import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification import org.schabi.newpipe.error.ReCaptchaActivity import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.StreamingService import org.schabi.newpipe.extractor.StreamingService.LinkType import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException import org.schabi.newpipe.extractor.exceptions.PaidContentException import org.schabi.newpipe.extractor.exceptions.PrivateContentException import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler import org.schabi.newpipe.extractor.playlist.PlaylistInfo import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.player.PlayerType import org.schabi.newpipe.player.helper.PlayerHelper import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.util.ChannelTabHelper import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.PermissionHelper import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.urlfinder.UrlFinder.Companion.firstUrlFromInput import org.schabi.newpipe.views.FocusOverlayView import java.io.Serializable import java.lang.ref.WeakReference import java.util.Arrays import java.util.Optional import java.util.concurrent.Callable import java.util.function.Function import java.util.function.IntPredicate import java.util.function.Predicate /** * Get the url from the intent and open it in the chosen preferred player. */ class RouterActivity() : AppCompatActivity() { protected val disposables: CompositeDisposable = CompositeDisposable() @State protected var currentServiceId: Int = -1 @State protected var currentLinkType: LinkType? = null @State protected var selectedRadioPosition: Int = -1 protected var selectedPreviously: Int = -1 protected var currentUrl: String? = null private var currentService: StreamingService? = null private var selectionIsDownload: Boolean = false private var selectionIsAddToPlaylist: Boolean = false private var alertDialogChoice: AlertDialog? = null private var dismissListener: FragmentManager.FragmentLifecycleCallbacks? = null override fun onCreate(savedInstanceState: Bundle?) { ThemeHelper.setDayNightMode(this) setTheme(if (ThemeHelper.isLightThemeSelected(this)) R.style.RouterActivityThemeLight else R.style.RouterActivityThemeDark) Localization.assureCorrectAppLanguage(this) // Pass-through touch events to background activities // so that our transparent window won't lock UI in the mean time // network request is underway before showing PlaylistDialog or DownloadDialog // (ref: https://stackoverflow.com/a/10606141) getWindow().addFlags((WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)) // Android never fails to impress us with a list of new restrictions per API. // Starting with S (Android 12) one of the prerequisite conditions has to be met // before the FLAG_NOT_TOUCHABLE flag is allowed to kick in: // @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE // For our present purpose it seems we can just set LayoutParams.alpha to 0 // on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs val params: WindowManager.LayoutParams = getWindow().getAttributes() params.alpha = 0f getWindow().setAttributes(params) super.onCreate(savedInstanceState) Icepick.restoreInstanceState(this, savedInstanceState) // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments // but those callbacks won't survive a config change // Try an alternate approach to hook into FragmentManager instead, to that effect // (ref: https://stackoverflow.com/a/44028453) val fm: FragmentManager = getSupportFragmentManager() if (dismissListener == null) { dismissListener = object : FragmentManager.FragmentLifecycleCallbacks() { public override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) { super.onFragmentDestroyed(fm, f) if (f is DialogFragment && fm.getFragments().isEmpty()) { // No more DialogFragments, we're done finish() } } } } fm.registerFragmentLifecycleCallbacks(dismissListener!!, false) if (TextUtils.isEmpty(currentUrl)) { currentUrl = getUrl(getIntent()) if (TextUtils.isEmpty(currentUrl)) { handleText() finish() } } } override fun onStop() { super.onStop() // we need to dismiss the dialog before leaving the activity or we get leaks if (alertDialogChoice != null) { alertDialogChoice!!.dismiss() } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Icepick.saveInstanceState(this, outState) } override fun onStart() { super.onStart() // Don't overlap the DialogFragment after rotating the screen // If there's no DialogFragment, we're either starting afresh // or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change if (getSupportFragmentManager().getFragments().isEmpty()) { // Start over from scratch handleUrl(currentUrl) } } override fun onDestroy() { super.onDestroy() if (dismissListener != null) { getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener!!) } disposables.clear() } public override fun finish() { // allow the activity to recreate in case orientation changes if (!isChangingConfigurations()) { super.finish() } } private fun handleUrl(url: String?) { disposables.add(Observable .fromCallable(Callable({ try { if (currentServiceId == -1) { currentService = NewPipe.getServiceByUrl(url) currentServiceId = currentService.getServiceId() currentLinkType = currentService.getLinkTypeByUrl(url) currentUrl = url } else { currentService = NewPipe.getService(currentServiceId) } // return whether the url was found to be supported or not return@fromCallable currentLinkType != LinkType.NONE } catch (e: ExtractionException) { // this can be reached only when the url is completely unsupported return@fromCallable false } })) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(io.reactivex.rxjava3.functions.Consumer({ isUrlSupported: Boolean -> if (isUrlSupported) { onSuccess() } else { showUnsupportedUrlDialog(url) } }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> handleError(this, ErrorInfo((throwable)!!, UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url)) }))) } protected fun showUnsupportedUrlDialog(url: String?) { val context: Context = getThemeWrapperContext() AlertDialog.Builder(context) .setTitle(R.string.unsupported_url) .setMessage(R.string.unsupported_url_dialog_message) .setIcon(R.drawable.ic_share) .setPositiveButton(R.string.open_in_browser, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> ShareUtils.openUrlInBrowser(this, url) })) .setNegativeButton(R.string.share, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> shareText(this, "", url) })) // no subject .setNeutralButton(R.string.cancel, null) .setOnDismissListener(DialogInterface.OnDismissListener({ dialog: DialogInterface? -> finish() })) .show() } protected fun onSuccess() { val preferences: SharedPreferences = PreferenceManager .getDefaultSharedPreferences(this) val choiceChecker: ChoiceAvailabilityChecker = ChoiceAvailabilityChecker( getChoicesForService(currentService, currentLinkType), (preferences.getString(getString(R.string.preferred_open_action_key), getString(R.string.preferred_open_action_default)))!!) // Check for non-player related choices if (choiceChecker.isAvailableAndSelected( R.string.show_info_key, R.string.download_key, R.string.add_to_playlist_key)) { handleChoice(choiceChecker.getSelectedChoiceKey()) return } // Check if the choice is player related if (choiceChecker.isAvailableAndSelected( R.string.video_player_key, R.string.background_player_key, R.string.popup_player_key)) { val selectedChoice: String = choiceChecker.getSelectedChoiceKey() val isExtVideoEnabled: Boolean = preferences.getBoolean( getString(R.string.use_external_video_player_key), false) val isExtAudioEnabled: Boolean = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false) val isVideoPlayerSelected: Boolean = ((selectedChoice == getString(R.string.video_player_key)) || (selectedChoice == getString(R.string.popup_player_key))) val isAudioPlayerSelected: Boolean = (selectedChoice == getString(R.string.background_player_key)) if ((currentLinkType != LinkType.STREAM && ((isExtAudioEnabled && isAudioPlayerSelected) || (isExtVideoEnabled && isVideoPlayerSelected)))) { Toast.makeText(this, R.string.external_player_unsupported_link_type, Toast.LENGTH_LONG).show() handleChoice(getString(R.string.show_info_key)) return } val capabilities: List = currentService!!.getServiceInfo().getMediaCapabilities() // Check if the service supports the choice if (((isVideoPlayerSelected && capabilities.contains(MediaCapability.VIDEO)) || (isAudioPlayerSelected && capabilities.contains(MediaCapability.AUDIO)))) { handleChoice(selectedChoice) } else { handleChoice(getString(R.string.show_info_key)) } return } // Default / Ask always val availableChoices: List = choiceChecker.getAvailableChoices() when (availableChoices.size) { 1 -> handleChoice(availableChoices.get(0).key) 0 -> handleChoice(getString(R.string.show_info_key)) else -> showDialog(availableChoices) } } /** * This is a helper class for checking if the choices are available and/or selected. */ internal inner class ChoiceAvailabilityChecker( private val availableChoices: List, private val selectedChoiceKey: String) { fun getAvailableChoices(): List { return availableChoices } fun getSelectedChoiceKey(): String { return selectedChoiceKey } fun isAvailableAndSelected(@StringRes vararg wantedKeys: Int): Boolean { return Arrays.stream(wantedKeys).anyMatch(IntPredicate({ wantedKey: Int -> this.isAvailableAndSelected(wantedKey) })) } fun isAvailableAndSelected(@StringRes wantedKey: Int): Boolean { val wanted: String = getString(wantedKey) // Check if the wanted option is selected if (!(selectedChoiceKey == wanted)) { return false } // Check if it's available return availableChoices.stream().anyMatch(Predicate({ item: AdapterChoiceItem -> (wanted == item.key) })) } } private fun showDialog(choices: List) { val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) val themeWrapperContext: Context = getThemeWrapperContext() val layoutInflater: LayoutInflater = LayoutInflater.from(themeWrapperContext) val binding: SingleChoiceDialogViewBinding = SingleChoiceDialogViewBinding.inflate(layoutInflater) val radioGroup: RadioGroup = binding.list val dialogButtonsClickListener: DialogInterface.OnClickListener = DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> val indexOfChild: Int = radioGroup.indexOfChild( radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())) val choice: AdapterChoiceItem = choices.get(indexOfChild) handleChoice(choice.key) // open future streams always like this one, because "always" button was used by user if (which == DialogInterface.BUTTON_POSITIVE) { preferences.edit() .putString(getString(R.string.preferred_open_action_key), choice.key) .apply() } }) alertDialogChoice = AlertDialog.Builder(themeWrapperContext) .setTitle(R.string.preferred_open_action_share_menu_title) .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.just_once, dialogButtonsClickListener) .setPositiveButton(R.string.always, dialogButtonsClickListener) .setOnDismissListener(DialogInterface.OnDismissListener({ dialog: DialogInterface? -> if (!selectionIsDownload && !selectionIsAddToPlaylist) { finish() } })) .create() alertDialogChoice!!.setOnShowListener(OnShowListener({ dialog: DialogInterface? -> setDialogButtonsState( alertDialogChoice!!, radioGroup.getCheckedRadioButtonId() != -1) })) radioGroup.setOnCheckedChangeListener(RadioGroup.OnCheckedChangeListener({ group: RadioGroup?, checkedId: Int -> setDialogButtonsState(alertDialogChoice!!, true) })) val radioButtonsClickListener: View.OnClickListener = View.OnClickListener({ v: View? -> val indexOfChild: Int = radioGroup.indexOfChild(v) if (indexOfChild == -1) { return@OnClickListener } selectedPreviously = selectedRadioPosition selectedRadioPosition = indexOfChild if (selectedPreviously == selectedRadioPosition) { handleChoice(choices.get(selectedRadioPosition).key) } }) var id: Int = 12345 for (item: AdapterChoiceItem in choices) { val radioButton: RadioButton = ListRadioIconItemBinding.inflate(layoutInflater) .getRoot() radioButton.setText(item.description) radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds( AppCompatResources.getDrawable(themeWrapperContext, item.icon), null, null, null) radioButton.setChecked(false) radioButton.setId(id++) radioButton.setLayoutParams(RadioGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) radioButton.setOnClickListener(radioButtonsClickListener) radioGroup.addView(radioButton) } if (selectedRadioPosition == -1) { val lastSelectedPlayer: String? = preferences.getString( getString(R.string.preferred_open_action_last_selected_key), null) if (!TextUtils.isEmpty(lastSelectedPlayer)) { for (i in choices.indices) { val c: AdapterChoiceItem = choices.get(i) if ((lastSelectedPlayer == c.key)) { selectedRadioPosition = i break } } } } selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size - 1) if (selectedRadioPosition != -1) { (radioGroup.getChildAt(selectedRadioPosition) as RadioButton).setChecked(true) } selectedPreviously = selectedRadioPosition alertDialogChoice!!.show() if (DeviceUtils.isTv(this)) { FocusOverlayView.Companion.setupFocusObserver(alertDialogChoice!!) } } private fun getChoicesForService(service: StreamingService?, linkType: LinkType?): List { val showInfo: AdapterChoiceItem = AdapterChoiceItem( getString(R.string.show_info_key), getString(R.string.show_info), R.drawable.ic_info_outline) val videoPlayer: AdapterChoiceItem = AdapterChoiceItem( getString(R.string.video_player_key), getString(R.string.video_player), R.drawable.ic_play_arrow) val backgroundPlayer: AdapterChoiceItem = AdapterChoiceItem( getString(R.string.background_player_key), getString(R.string.background_player), R.drawable.ic_headset) val popupPlayer: AdapterChoiceItem = AdapterChoiceItem( getString(R.string.popup_player_key), getString(R.string.popup_player), R.drawable.ic_picture_in_picture) val returnedItems: MutableList = ArrayList() returnedItems.add(showInfo) // Always present val capabilities: List = service!!.getServiceInfo().getMediaCapabilities() if (linkType == LinkType.STREAM) { if (capabilities.contains(MediaCapability.VIDEO)) { returnedItems.add(videoPlayer) returnedItems.add(popupPlayer) } if (capabilities.contains(MediaCapability.AUDIO)) { returnedItems.add(backgroundPlayer) } // download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is // not supported ) returnedItems.add(AdapterChoiceItem(getString(R.string.download_key), getString(R.string.download), R.drawable.ic_file_download)) // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can // not be added to a playlist returnedItems.add(AdapterChoiceItem(getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist), R.drawable.ic_add)) } else { // LinkType.NONE is never present because it's filtered out before // channels and playlist can be played as they contain a list of videos val preferences: SharedPreferences = PreferenceManager .getDefaultSharedPreferences(this) val isExtVideoEnabled: Boolean = preferences.getBoolean( getString(R.string.use_external_video_player_key), false) val isExtAudioEnabled: Boolean = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false) if (capabilities.contains(MediaCapability.VIDEO) && !isExtVideoEnabled) { returnedItems.add(videoPlayer) returnedItems.add(popupPlayer) } if (capabilities.contains(MediaCapability.AUDIO) && !isExtAudioEnabled) { returnedItems.add(backgroundPlayer) } } return returnedItems } protected fun getThemeWrapperContext(): Context { return ContextThemeWrapper(this, if (ThemeHelper.isLightThemeSelected(this)) R.style.LightTheme else R.style.DarkTheme) } private fun setDialogButtonsState(dialog: AlertDialog, state: Boolean) { val negativeButton: Button? = dialog.getButton(DialogInterface.BUTTON_NEGATIVE) val positiveButton: Button? = dialog.getButton(DialogInterface.BUTTON_POSITIVE) if (negativeButton == null || positiveButton == null) { return } negativeButton.setEnabled(state) positiveButton.setEnabled(state) } private fun handleText() { val searchString: String? = getIntent().getStringExtra(Intent.EXTRA_TEXT) val serviceId: Int = getIntent().getIntExtra(KEY_SERVICE_ID, 0) val intent: Intent = Intent(getThemeWrapperContext(), MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) NavigationHelper.openSearch(getThemeWrapperContext(), serviceId, searchString) } private fun handleChoice(selectedChoiceKey: String) { val validChoicesList: List = Arrays.asList(*getResources() .getStringArray(R.array.preferred_open_action_values_list)) if (validChoicesList.contains(selectedChoiceKey)) { PreferenceManager.getDefaultSharedPreferences(this).edit() .putString(getString( R.string.preferred_open_action_last_selected_key), selectedChoiceKey) .apply() } if (((selectedChoiceKey == getString(R.string.popup_player_key)) && !PermissionHelper.isPopupEnabledElseAsk(this))) { finish() return } if ((selectedChoiceKey == getString(R.string.download_key))) { if (PermissionHelper.checkStoragePermissions(this, PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { selectionIsDownload = true openDownloadDialog() } return } if ((selectedChoiceKey == getString(R.string.add_to_playlist_key))) { selectionIsAddToPlaylist = true openAddToPlaylistDialog() return } // stop and bypass FetcherService if InfoScreen was selected since // StreamDetailFragment can fetch data itself if (((selectedChoiceKey == getString(R.string.show_info_key)) || canHandleChoiceLikeShowInfo(selectedChoiceKey))) { disposables.add(Observable .fromCallable(Callable({ NavigationHelper.getIntentByLink(this, currentUrl) })) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(io.reactivex.rxjava3.functions.Consumer({ intent: Intent? -> startActivity(intent) finish() }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> handleError(this, ErrorInfo((throwable)!!, UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)) })) ) return } val intent: Intent = Intent(this, FetcherService::class.java) val choice: Choice = Choice(currentService!!.getServiceId(), currentLinkType, currentUrl, selectedChoiceKey) intent.putExtra(FetcherService.KEY_CHOICE, choice) startService(intent) finish() } private fun canHandleChoiceLikeShowInfo(selectedChoiceKey: String): Boolean { if (!(selectedChoiceKey == getString(R.string.video_player_key))) { return false } // "video player" can be handled like "show info" (because VideoDetailFragment can load // the stream instead of FetcherService) when... // ...Autoplay is enabled if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) { return false } val isExtVideoEnabled: Boolean = PreferenceManager.getDefaultSharedPreferences(this) .getBoolean(getString(R.string.use_external_video_player_key), false) // ...it's not done via an external player if (isExtVideoEnabled) { return false } // ...the player is not running or in normal Video-mode/type val playerType: PlayerType? = PlayerHolder.Companion.getInstance().getType() return playerType == null || playerType == PlayerType.MAIN } class PersistentFragment() : Fragment() { private var weakContext: WeakReference? = null private val disposables: CompositeDisposable = CompositeDisposable() private var running: Int = 0 @Synchronized private fun inFlight(started: Boolean) { if (started) { running++ } else { running-- if (running <= 0) { getActivityContext().ifPresent(java.util.function.Consumer({ context: AppCompatActivity? -> context!!.getSupportFragmentManager() .beginTransaction().remove(this).commit() })) } } } public override fun onAttach(activityContext: Context) { super.onAttach(activityContext) weakContext = WeakReference(activityContext as AppCompatActivity) } public override fun onDetach() { super.onDetach() weakContext = null } @Suppress("deprecation") public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setRetainInstance(true) } public override fun onDestroy() { super.onDestroy() disposables.clear() } /** * @return the activity context, if there is one and the activity is not finishing */ private fun getActivityContext(): Optional { return Optional.ofNullable(weakContext) .map(Function({ obj: WeakReference? -> obj!!.get() })) .filter(Predicate({ context: AppCompatActivity? -> !context!!.isFinishing() })) } // guard against IllegalStateException in calling DialogFragment.show() whilst in background // (which could happen, say, when the user pressed the home button while waiting for // the network request to return) when it internally calls FragmentTransaction.commit() // after the FragmentManager has saved its states (isStateSaved() == true) // (ref: https://stackoverflow.com/a/39813506) private fun runOnVisible(runnable: java.util.function.Consumer) { getActivityContext().ifPresentOrElse(java.util.function.Consumer({ context: AppCompatActivity? -> if (getLifecycle().currentState.isAtLeast(Lifecycle.State.STARTED)) { context!!.runOnUiThread(Runnable({ runnable.accept((context)) inFlight(false) })) } else { getLifecycle().addObserver(object : DefaultLifecycleObserver { public override fun onResume(owner: LifecycleOwner) { getLifecycle().removeObserver(this) getActivityContext().ifPresentOrElse(java.util.function.Consumer({ context: AppCompatActivity? -> context!!.runOnUiThread(Runnable({ runnable.accept((context)) inFlight(false) })) }), Runnable({ inFlight(false) }) ) } }) // this trick doesn't seem to work on Android 10+ (API 29) // which places restrictions on starting activities from the background if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !context!!.isChangingConfigurations())) { // try to bring the activity back to front if minimised val i: Intent = Intent(context, RouterActivity::class.java) i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) startActivity(i) } } }), Runnable({ // this branch is executed if there is no activity context inFlight(false) }) ) } fun pleaseWait(single: Single): Single { // 'abuse' ambWith() here to cancel the toast for us when the wait is over return single.ambWith(Single.create(SingleOnSubscribe({ emitter: SingleEmitter -> getActivityContext().ifPresent(java.util.function.Consumer({ context: AppCompatActivity? -> context!!.runOnUiThread(Runnable({ // Getting the stream info usually takes a moment // Notifying the user here to ensure that no confusion arises val toast: Toast = Toast.makeText(context, getString(R.string.processing_may_take_a_moment), Toast.LENGTH_LONG) toast.show() emitter.setCancellable(Cancellable({ toast.cancel() })) })) })) }))) } @SuppressLint("CheckResult") fun openDownloadDialog(currentServiceId: Int, currentUrl: String?) { inFlight(true) val loadingDialog: LoadingDialog = LoadingDialog(R.string.loading_metadata_title) loadingDialog.show(getParentFragmentManager(), "loadingDialog") disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .compose(SingleTransformer({ single: Single -> pleaseWait(single) })) .subscribe(io.reactivex.rxjava3.functions.Consumer({ result: StreamInfo -> runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> loadingDialog.dismiss() val fm: FragmentManager = ctx.getSupportFragmentManager() val downloadDialog: DownloadDialog = DownloadDialog(ctx, result) // dismiss listener to be handled by FragmentManager downloadDialog.show(fm, "downloadDialog") }) ) }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> loadingDialog.dismiss() (ctx as RouterActivity).showUnsupportedUrlDialog(currentUrl) })) }))) } fun openAddToPlaylistDialog(currentServiceId: Int, currentUrl: String?) { inFlight(true) disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .compose(SingleTransformer({ single: Single -> pleaseWait(single) })) .subscribe( io.reactivex.rxjava3.functions.Consumer({ info: StreamInfo? -> getActivityContext().ifPresent(java.util.function.Consumer({ context: AppCompatActivity? -> PlaylistDialog.Companion.createCorrespondingDialog(context, java.util.List.of(StreamEntity((info)!!)), java.util.function.Consumer({ playlistDialog: PlaylistDialog -> runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> // dismiss listener to be handled by FragmentManager val fm: FragmentManager = ctx.getSupportFragmentManager() playlistDialog.show(fm, "addToPlaylistDialog") })) }) ) })) }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> handleError(ctx, ErrorInfo( (throwable)!!, UserAction.REQUESTED_STREAM, "Tried to add " + currentUrl + " to a playlist", (ctx as RouterActivity).currentService!!.getServiceId()) ) })) }) ) ) } } private fun openAddToPlaylistDialog() { getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl) } private fun openDownloadDialog() { getPersistFragment().openDownloadDialog(currentServiceId, currentUrl) } private fun getPersistFragment(): PersistentFragment { val fm: FragmentManager = getSupportFragmentManager() var persistFragment: PersistentFragment? = fm.findFragmentByTag("PERSIST_FRAGMENT") as PersistentFragment? if (persistFragment == null) { persistFragment = PersistentFragment() fm.beginTransaction() .add(persistFragment, "PERSIST_FRAGMENT") .commitNow() } return persistFragment } public override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) for (i: Int in grantResults) { if (i == PackageManager.PERMISSION_DENIED) { finish() return } } if (requestCode == PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE) { openDownloadDialog() } } private class AdapterChoiceItem internal constructor(val key: String, val description: String, @field:DrawableRes val icon: Int) class Choice internal constructor(val serviceId: Int, val linkType: LinkType?, val url: String?, val playerChoice: String) : Serializable { public override fun toString(): String { return serviceId.toString() + ":" + url + " > " + linkType + " ::: " + playerChoice } } class FetcherService() : IntentService(FetcherService::class.java.getSimpleName()) { private var fetcher: Disposable? = null public override fun onCreate() { super.onCreate() startForeground(ID, createNotification().build()) } override fun onHandleIntent(intent: Intent?) { if (intent == null) { return } val serializable: Serializable? = intent.getSerializableExtra(KEY_CHOICE) if (!(serializable is Choice)) { return } handleChoice(serializable) } fun handleChoice(choice: Choice) { var single: Single? = null var userAction: UserAction = UserAction.SOMETHING_ELSE when (choice.linkType) { LinkType.STREAM -> { single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false) userAction = UserAction.REQUESTED_STREAM } LinkType.CHANNEL -> { single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false) userAction = UserAction.REQUESTED_CHANNEL } LinkType.PLAYLIST -> { single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false) userAction = UserAction.REQUESTED_PLAYLIST } } if (single != null) { val finalUserAction: UserAction = userAction val resultHandler: java.util.function.Consumer = getResultHandler(choice) fetcher = single .observeOn(AndroidSchedulers.mainThread()) .subscribe({ info: Info? -> resultHandler.accept(info) if (fetcher != null) { fetcher!!.dispose() } }, io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> handleError(this, ErrorInfo((throwable)!!, finalUserAction, choice.url + " opened with " + choice.playerChoice, choice.serviceId)) })) } } fun getResultHandler(choice: Choice): java.util.function.Consumer { return java.util.function.Consumer({ info: Info? -> val videoPlayerKey: String = getString(R.string.video_player_key) val backgroundPlayerKey: String = getString(R.string.background_player_key) val popupPlayerKey: String = getString(R.string.popup_player_key) val preferences: SharedPreferences = PreferenceManager .getDefaultSharedPreferences(this) val isExtVideoEnabled: Boolean = preferences.getBoolean( getString(R.string.use_external_video_player_key), false) val isExtAudioEnabled: Boolean = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false) val playQueue: PlayQueue if (info is StreamInfo) { if ((choice.playerChoice == backgroundPlayerKey) && isExtAudioEnabled) { NavigationHelper.playOnExternalAudioPlayer(this, info) return@Consumer } else if ((choice.playerChoice == videoPlayerKey) && isExtVideoEnabled) { NavigationHelper.playOnExternalVideoPlayer(this, info) return@Consumer } playQueue = SinglePlayQueue(info as StreamInfo?) } else if (info is ChannelInfo) { val playableTab: Optional = info.getTabs() .stream() .filter(Predicate({ obj: ListLinkHandler? -> ChannelTabHelper.isStreamsTab() })) .findFirst() if (playableTab.isPresent()) { playQueue = ChannelTabPlayQueue(info.getServiceId(), playableTab.get()) } else { return@Consumer // there is no playable tab } } else if (info is PlaylistInfo) { playQueue = PlaylistPlayQueue(info) } else { return@Consumer } if ((choice.playerChoice == videoPlayerKey)) { NavigationHelper.playOnMainPlayer(this, playQueue, false) } else if ((choice.playerChoice == backgroundPlayerKey)) { NavigationHelper.playOnBackgroundPlayer(this, playQueue, true) } else if ((choice.playerChoice == popupPlayerKey)) { NavigationHelper.playOnPopupPlayer(this, playQueue, true) } }) } public override fun onDestroy() { super.onDestroy() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) if (fetcher != null) { fetcher!!.dispose() } } private fun createNotification(): NotificationCompat.Builder { return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) .setOngoing(true) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentTitle( getString(R.string.preferred_player_fetcher_notification_title)) .setContentText( getString(R.string.preferred_player_fetcher_notification_message)) } companion object { val KEY_CHOICE: String = "key_choice" private val ID: Int = 456 } } /*////////////////////////////////////////////////////////////////////////// // Utils ////////////////////////////////////////////////////////////////////////// */ private fun getUrl(intent: Intent): String? { var foundUrl: String? = null if (intent.getData() != null) { // Called from another app foundUrl = intent.getData().toString() } else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) { // Called from the share menu val extraText: String? = intent.getStringExtra(Intent.EXTRA_TEXT) foundUrl = firstUrlFromInput(extraText) } return foundUrl } companion object { /** * @param context the context. It will be `finish()`ed at the end of the handling if it is * an instance of [RouterActivity]. * @param errorInfo the error information */ private fun handleError(context: Context, errorInfo: ErrorInfo) { if (errorInfo.throwable != null) { errorInfo.throwable!!.printStackTrace() } if (errorInfo.throwable is ReCaptchaException) { Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show() // Starting ReCaptcha Challenge Activity val intent: Intent = Intent(context, ReCaptchaActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } else if ((errorInfo.throwable != null && errorInfo.throwable!!.isNetworkRelated)) { Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is AgeRestrictedContentException) { Toast.makeText(context, R.string.restricted_video_no_stream, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is GeographicRestrictionException) { Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is PaidContentException) { Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is PrivateContentException) { Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is SoundCloudGoPlusContentException) { Toast.makeText(context, R.string.soundcloud_go_plus_content, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is YoutubeMusicPremiumContentException) { Toast.makeText(context, R.string.youtube_music_premium_content, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is ContentNotAvailableException) { Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show() } else if (errorInfo.throwable is ContentNotSupportedException) { Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show() } else { createNotification(context, errorInfo) } if (context is RouterActivity) { context.finish() } } } }