@@ -15,6 +15,7 @@ import app.morphe.manager.R
1515import app.revanced.manager.domain.bundles.PatchBundleSource
1616import app.revanced.manager.domain.repository.PatchBundleRepository.Companion.DEFAULT_SOURCE_UID
1717import app.revanced.manager.domain.repository.PatchOptionsRepository
18+ import app.revanced.manager.network.api.MORPHE_API_URL
1819import app.revanced.manager.patcher.patch.PatchBundleInfo
1920import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
2021import app.revanced.manager.ui.model.SelectedApp
@@ -30,6 +31,9 @@ import kotlinx.coroutines.launch
3031import kotlinx.coroutines.withContext
3132import org.koin.compose.koinInject
3233import java.io.File
34+ import java.net.HttpURLConnection
35+ import java.net.SocketTimeoutException
36+ import java.net.URL
3337import java.net.URLEncoder.encode
3438
3539private const val PACKAGE_YOUTUBE = " com.google.android.youtube"
@@ -95,6 +99,7 @@ class HomeStates(
9599 var pendingAppName by mutableStateOf<String ?>(null )
96100 var pendingRecommendedVersion by mutableStateOf<String ?>(null )
97101 var pendingSelectedApp by mutableStateOf<SelectedApp ?>(null )
102+ var resolvedDownloadUrl by mutableStateOf<String ?>(null )
98103
99104 // Bundle update snackbar state
100105 var showBundleUpdateSnackbar by mutableStateOf(false )
@@ -255,38 +260,104 @@ class HomeStates(
255260 onStartQuickPatch(params)
256261 }
257262
258- /* *
259- * Handle download instructions dialog continue action
260- * Opens browser to APKMirror search and shows file picker prompt
261- */
262- fun handleDownloadInstructionsContinue (uriHandler : UriHandler ) {
263- val baseQuery = if (pendingPackageName == PACKAGE_YOUTUBE ) {
264- pendingPackageName
265- } else {
266- // Some versions of YT Music don't show when the package name is used, use the app name instead
267- " YouTube Music"
263+ // TODO: Move this logic somewhere more appropriate.
264+ fun resolveDownloadRedirect () {
265+ fun resolveUrlRedirect (url : String ): String {
266+ return try {
267+ val originalUrl = URL (url)
268+ val connection = originalUrl.openConnection() as HttpURLConnection
269+ connection.instanceFollowRedirects = false
270+ connection.requestMethod = " HEAD"
271+ connection.connectTimeout = 5_000
272+ connection.readTimeout = 5_000
273+
274+ val responseCode = connection.responseCode
275+ if (responseCode in 300 .. 399 ) {
276+ val location = connection.getHeaderField(" Location" )
277+
278+ if (location.isNullOrBlank()) {
279+ Log .d(tag, " Location tag is blank: ${connection.responseMessage} " )
280+ getApiOfflineWebSearchUrl()
281+ } else {
282+ val resolved =
283+ if (location.startsWith(" http://" ) || location.startsWith(" https://" )) {
284+ location
285+ } else {
286+ val prefix = " ${originalUrl.protocol} ://${originalUrl.host} "
287+ if (location.startsWith(" /" )) " $prefix$location " else " $prefix /$location "
288+ }
289+ Log .d(tag, " Result: $resolved " )
290+ resolved
291+ }
292+ } else {
293+ Log .d(tag, " Unexpected response code: $responseCode " )
294+ getApiOfflineWebSearchUrl()
295+ }
296+ } catch (ex: SocketTimeoutException ) {
297+ Log .d(tag, " Timeout while resolving search redirect: $ex " )
298+ // Timeout may be because the network is very slow.
299+ // Still use web-search api call in external browser.
300+ url
301+ } catch (ex: Exception ) {
302+ Log .d(tag, " Exception while resolving search redirect: $ex " )
303+ getApiOfflineWebSearchUrl()
304+ }
268305 }
269306
307+ // Must not escape colon search term separator, but recommended version must be escaped
308+ // because Android version string can be almost anything.
309+ val escapedVersion = encode(pendingRecommendedVersion, " UTF-8" )
310+ val searchQuery = " $pendingPackageName :$escapedVersion :${Build .SUPPORTED_ABIS .first()} "
311+ // To test client fallback logic in getApiOfflineWebSearchUrl(), change this an invalid url.
312+ val searchUrl = " $MORPHE_API_URL /v1/web-search/$searchQuery "
313+ Log .d(tag, " Using search url: $searchUrl " )
314+
315+ // Use API web-search if user clicks thru faster than redirect resolving can occur.
316+ resolvedDownloadUrl = searchUrl
317+
318+ scope.launch(Dispatchers .IO ) {
319+ var resolved = resolveUrlRedirect(searchUrl)
320+
321+ // If redirect stays on api.morphe.software, try resolving again
322+ if (resolved.startsWith(MORPHE_API_URL )) {
323+ Log .d(tag, " Redirect still on API host, resolving again" )
324+ resolved = resolveUrlRedirect(resolved)
325+ }
326+
327+ withContext(Dispatchers .Main ) {
328+ resolvedDownloadUrl = resolved
329+ }
330+ }
331+ }
332+
333+ fun getApiOfflineWebSearchUrl (): String {
270334 val architecture = if (pendingPackageName == PACKAGE_YOUTUBE_MUSIC ) {
271335 // YT Music requires architecture. This logic could be improved
272336 " (${Build .SUPPORTED_ABIS .first()} )"
273337 } else {
274- " "
338+ " nodpi "
275339 }
276340
277- val version = pendingRecommendedVersion ? : " "
278- // Backslash search parameter opens the first search result
279- // Use quotes to ensure it's an exact match of all search terms
280- val searchQuery = " \\ $baseQuery $version $architecture (nodpi) site:apkmirror.com" .replace(" " , " " )
281- val searchUrl = " https://duckduckgo.com/?q=${encode(searchQuery, " UTF-8" )} "
341+ val searchQuery = " \" $pendingPackageName \" \" $pendingRecommendedVersion \" \" $architecture \" site:APKMirror.com"
342+
343+ val searchUrl = " https://google.com/search?q=${encode(searchQuery, " UTF-8" )} "
282344 Log .d(tag, " Using search query: $searchQuery " )
345+ return searchUrl
346+ }
347+
348+ /* *
349+ * Handle download instructions dialog continue action
350+ * Opens browser to APKMirror search and shows file picker prompt.
351+ */
352+ fun handleDownloadInstructionsContinue (uriHandler : UriHandler ) {
353+ val urlToOpen = resolvedDownloadUrl!!
283354
284355 try {
285- uriHandler.openUri(searchUrl)
286- // After opening browser, show file picker prompt
356+ uriHandler.openUri(urlToOpen)
287357 showDownloadInstructionsDialog = false
288358 showFilePickerPromptDialog = true
289- } catch (_: Exception ) {
359+ } catch (ex: Exception ) {
360+ Log .d(tag, " Failed to open URL: $ex " )
290361 context.toast(context.getString(R .string.morphe_home_failed_to_open_url))
291362 showDownloadInstructionsDialog = false
292363 cleanupPendingData()
@@ -311,6 +382,7 @@ class HomeStates(
311382 pendingPackageName = null
312383 pendingAppName = null
313384 pendingRecommendedVersion = null
385+ resolvedDownloadUrl = null
314386 if (! keepSelectedApp) {
315387 // Delete temporary file if exists
316388 pendingSelectedApp?.let { app ->
0 commit comments