package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.Uri import android.os.Build import android.os.Environment import android.os.ParcelFileDescriptor import android.provider.MediaStore import android.text.Spanned import android.util.Log import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.PreviewChannelHelper import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.WatchNextProgram import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import com.fasterxml.jackson.module.kotlin.readValue import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.ui.WebviewFragment import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel.Companion.downloadAll import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate import okhttp3.Cache import java.io.* import java.net.URL import java.net.URLDecoder object AppUtils { fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { for (i in 0..maxViewTypeId) recycledViewPool.setMaxRecycledViews(i, maxPoolSize) } fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? val adapter = adapter return if (layoutManager == null || adapter == null) false else layoutManager.findLastCompletelyVisibleItemPosition() < adapter.itemCount - 7 // bit more than 1 to make it more seamless } //fun Context.deleteFavorite(data: SearchResponse) { // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // normalSafeApiCall { // val existingId = // getWatchNextProgramByVideoId(data.url, this).second ?: return@normalSafeApiCall // contentResolver.delete( // // TvContractCompat.buildWatchNextProgramUri(existingId), // null, null // ) // } //} fun String?.html(): Spanned { return getHtmlText(this ?: return "".toSpanned()) } private fun getHtmlText(text: String): Spanned { return try { // I have no idea if this can throw any error, but I dont want to try HtmlCompat.fromHtml( text, HtmlCompat.FROM_HTML_MODE_LEGACY ) } catch (e: Exception) { logError(e) text.toSpanned() } } @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { context.getNameFull(card.name, card.episode, card.season) } else { card.name } val builder = WatchNextProgram.Builder() .setEpisodeTitle(title) .setType( if (isSeries) { TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE } else TvContractCompat.WatchNextPrograms.TYPE_MOVIE ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) .setPosterArtUri(Uri.parse(card.posterUrl)) .setIntentUri(Uri.parse(card.url)) //TODO FIX intent .setInternalProviderId(card.url) //.setLastEngagementTimeUtcMillis(System.currentTimeMillis()) card.watchPos?.let { builder.setDurationMillis(it.duration.toInt()) builder.setLastPlaybackPositionMillis(it.position.toInt()) } // .setLastEngagementTimeUtcMillis() //TODO if (isSeries) card.episode?.let { builder.setEpisodeNumber(it) } return builder.build() } /** * Find the Watch Next program for given id. * Returns the first instance available. */ @SuppressLint("RestrictedApi") // Suppress RestrictedApi due to https://issuetracker.google.com/138150076 fun findFirstWatchNextProgram(context: Context, predicate: (Cursor) -> Boolean): Pair { val COLUMN_WATCH_NEXT_ID_INDEX = 0 // val COLUMN_WATCH_NEXT_INTERNAL_PROVIDER_ID_INDEX = 1 // val COLUMN_WATCH_NEXT_COLUMN_BROWSABLE_INDEX = 2 val cursor = context.contentResolver.query( TvContractCompat.WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, /* selection = */ null, /* selectionArgs = */ null, /* sortOrder= */ null ) cursor?.use { if (it.moveToFirst()) { do { if (predicate(cursor)) { return fromCursor(cursor) to cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX) } } while (it.moveToNext()) } } return null to null } /** * Query the Watch Next list and find the program with given videoId. * Return null if not found. */ @RequiresApi(Build.VERSION_CODES.O) @SuppressLint("Range") @Synchronized private fun getWatchNextProgramByVideoId( id: String, context: Context ): Pair { return findFirstWatchNextProgram(context) { cursor -> (cursor.getString(cursor.getColumnIndex(COLUMN_INTERNAL_PROVIDER_ID)) == id) } } // https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java @SuppressLint("RestrictedApi") @WorkerThread fun Context.addProgramsToContinueWatching(data: List) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val context = this ioSafe { data.forEach { episodeInfo -> try { val (program, id) = getWatchNextProgramByVideoId(episodeInfo.url, context) val nextProgram = buildWatchNextProgramUri(context, episodeInfo) // If the program is already in the Watch Next row, update it if (program != null && id != null) { PreviewChannelHelper(context).updateWatchNextProgram( nextProgram, id, ) } else { PreviewChannelHelper(context) .publishWatchNextProgram(nextProgram) } } catch (e: Exception) { logError(e) } } } } @SuppressLint("Range") fun getVideoContentUri(context: Context, videoFilePath: String): Uri? { val cursor = context.contentResolver.query( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Video.Media._ID), MediaStore.Video.Media.DATA + "=? ", arrayOf(videoFilePath), null ) return if (cursor != null && cursor.moveToFirst()) { val id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID)) cursor.close() Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "" + id) } else { val values = ContentValues() values.put(MediaStore.Video.Media.DATA, videoFilePath) context.contentResolver.insert( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values ) } } fun Activity.loadRepository(url: String) { ioSafe { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( repo.name, url ) ) main { showToast( this@loadRepository, getString(R.string.player_loaded_subtitles, repo.name), Toast.LENGTH_LONG ) } afterRepositoryLoadedEvent.invoke(true) downloadAllPluginsDialog(url, repo.name) } } fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { runOnUiThread { val context = this val builder: AlertDialog.Builder = AlertDialog.Builder(this) builder.setTitle( repositoryName ) builder.setMessage( R.string.download_all_plugins_from_repo ) builder.apply { setPositiveButton(R.string.download) { _, _ -> downloadAll(context, repositoryUrl, null) } setNegativeButton(R.string.cancel) { _, _ -> } } builder.show() } } private fun Context.hasWebView(): Boolean { return this.packageManager.hasSystemFeature("android.software.webview") } private fun openWebView(fragment: Fragment?, url: String) { if (fragment?.context?.hasWebView() == true) normalSafeApiCall { fragment .findNavController() .navigate(R.id.navigation_webview, WebviewFragment.newInstance(url)) } } /** * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails. * */ fun Context.openBrowser( url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null, ) { try { val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing // On older versions the startActivity just crashes, but on newer android versions // You need to check the result to make sure it failed val activityResultRegistry = fragment?.activity?.activityResultRegistry if (activityResultRegistry != null) { activityResultRegistry.register( url, ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_CANCELED && fallbackWebview) { openWebView(fragment, url) } }.launch(intent) } else { ContextCompat.startActivity(this, intent, null) } } catch (e: Exception) { logError(e) if (fallbackWebview) { openWebView(fragment, url) } } } fun splitQuery(url: URL): Map { val queryPairs: MutableMap = LinkedHashMap() val query: String = url.query val pairs = query.split("&").toTypedArray() for (pair in pairs) { val idx = pair.indexOf("=") queryPairs[URLDecoder.decode(pair.substring(0, idx), "UTF-8")] = URLDecoder.decode(pair.substring(idx + 1), "UTF-8") } return queryPairs } /** Any object as json string */ fun Any.toJson(): String { if (this is String) return this return mapper.writeValueAsString(this) } inline fun parseJson(value: String): T { return mapper.readValue(value) } inline fun tryParseJson(value: String?): T? { return try { parseJson(value ?: return null) } catch (_: Exception) { null } } /**| S1:E2 Hello World * | Episode 2. Hello world * | Hello World * | Season 1 - Episode 2 * | Episode 2 * **/ fun Context.getNameFull(name: String?, episode: Int?, season: Int?): String { val rEpisode = if (episode == 0) null else episode val rSeason = if (season == 0) null else season val seasonName = getString(R.string.season) val episodeName = getString(R.string.episode) val seasonNameShort = getString(R.string.season_short) val episodeNameShort = getString(R.string.episode_short) if (name != null) { return if (rEpisode != null && rSeason != null) { "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode} $name" } else if (rEpisode != null) { "$episodeName $rEpisode. $name" } else { name } } else { if (rEpisode != null && rSeason != null) { return "$seasonName $rSeason - $episodeName $rEpisode" } else if (rSeason == null) { return "$episodeName $rEpisode" } } return "" } fun Activity?.loadCache() { try { cacheClass("android.net.NetworkCapabilities".load()) } catch (_: Exception) { } } //private val viewModel: ResultViewModel by activityViewModels() private fun getResultsId(context: Context): Int { return if (context.isTrueTvSettings()) { R.id.global_to_navigation_results_tv } else { R.id.global_to_navigation_results_phone } } fun AppCompatActivity.loadResult( url: String, apiName: String, startAction: Int = 0, startValue: Int = 0 ) { this.runOnUiThread { // viewModelStore.clear() this.navigate( getResultsId(this.applicationContext ?: return@runOnUiThread), ResultFragment.newInstance(url, apiName, startAction, startValue) ) } } fun Activity?.loadSearchResult( card: SearchResponse, startAction: Int = 0, startValue: Int? = null, ) { this?.runOnUiThread { // viewModelStore.clear() this.navigate( getResultsId(this), ResultFragment.newInstance(card, startAction, startValue) ) } //(this as? AppCompatActivity?)?.loadResult(card.url, card.apiName, startAction, startValue) } fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) { val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.requestAudioFocus(focusRequest) } else { val audioManager: AudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.requestAudioFocus( null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK ) } } private var currentAudioFocusRequest: AudioFocusRequest? = null private var currentAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null var onAudioFocusEvent = Event() private fun getAudioListener(): AudioManager.OnAudioFocusChangeListener? { if (currentAudioFocusChangeListener != null) return currentAudioFocusChangeListener currentAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { onAudioFocusEvent.invoke( when (it) { AudioManager.AUDIOFOCUS_GAIN -> false AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> false AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> false else -> true } ) } return currentAudioFocusChangeListener } fun Context.isCastApiAvailable(): Boolean { val isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS try { applicationContext?.let { CastContext.getSharedInstance(it) } } catch (e: Exception) { println(e) // track non-fatal return false } return isCastApiAvailable } fun Context.isConnectedToChromecast(): Boolean { if (isCastApiAvailable()) { val castContext = CastContext.getSharedInstance(this) if (castContext.castState == CastState.CONNECTED) { return true } } return false } // Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt @SuppressLint("Range") fun Context.getUri(data: Uri?): Uri? { var uri = data val ctx = this if (data != null && data.scheme == "content") { // Mail-based apps - download the stream to a temporary file and play it if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) { var inputStream: InputStream? = null var os: OutputStream? = null var cursor: Cursor? = null try { cursor = ctx.contentResolver.query( data, arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null ) if (cursor != null && cursor.moveToFirst()) { val filename = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) .replace("/", "") inputStream = ctx.contentResolver.openInputStream(data) if (inputStream == null) return data os = FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename) val buffer = ByteArray(1024) var bytesRead = inputStream.read(buffer) while (bytesRead >= 0) { os.write(buffer, 0, bytesRead) bytesRead = inputStream.read(buffer) } uri = Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename)) } } catch (e: Exception) { return null } finally { inputStream?.close() os?.close() cursor?.close() } } else if (data.authority == "media") { uri = this.contentResolver.query( data, arrayOf(MediaStore.Video.Media.DATA), null, null, null )?.use { val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) ?: data else data } //uri = MediaUtils.getContentMediaUri(data) /*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) { println("TV AUTHORITY") //val medialibrary = Medialibrary.getInstance() //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) uri = null//media.uri*/ } else { val inputPFD: ParcelFileDescriptor? try { inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") if (inputPFD == null) return data uri = Uri.parse("fd://" + inputPFD.fd) // Cursor returnCursor = // getContentResolver().query(data, null, null, null, null); // if (returnCursor != null) { // if (returnCursor.getCount() > 0) { // int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); // if (nameIndex > -1) { // returnCursor.moveToFirst(); // title = returnCursor.getString(nameIndex); // } // } // returnCursor.close(); // } } catch (e: FileNotFoundException) { Log.e("TAG", "${e.message} for $data", e) return null } catch (e: IllegalArgumentException) { Log.e("TAG", "${e.message} for $data", e) return null } catch (e: IllegalStateException) { Log.e("TAG", "${e.message} for $data", e) return null } catch (e: NullPointerException) { Log.e("TAG", "${e.message} for $data", e) return null } catch (e: SecurityException) { Log.e("TAG", "${e.message} for $data", e) return null } }// Media or MMS URI } return uri } fun Context.isUsingMobileData(): Boolean { val conManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val networkInfo = conManager.allNetworks return networkInfo.any { conManager.getNetworkCapabilities(it) ?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true } } private fun Activity?.cacheClass(clazz: String?) { clazz?.let { c -> this?.cacheDir?.let { Cache( directory = File(it, c.toClassDir()), maxSize = 20L * 1024L * 1024L // 20 MiB ) } } } fun Context.isAppInstalled(uri: String): Boolean { val pm = Wrappers.packageManager(this) return try { pm.getPackageInfo(uri, 0) // PackageManager.GET_ACTIVITIES true } catch (e: PackageManager.NameNotFoundException) { false } } fun getFocusRequest(): AudioFocusRequest? { if (currentAudioFocusRequest != null) return currentAudioFocusRequest currentAudioFocusRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { setAudioAttributes(AudioAttributes.Builder().run { setUsage(AudioAttributes.USAGE_MEDIA) setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) build() }) setAcceptsDelayedFocusGain(true) getAudioListener()?.let { setOnAudioFocusChangeListener(it) } build() } } else { null } return currentAudioFocusRequest } }