package com.kododake.aabrowser.startpage import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.graphics.drawable.LayerDrawable import android.net.Uri import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.kododake.aabrowser.R import com.kododake.aabrowser.bookmarks.BookmarkManager import com.kododake.aabrowser.data.BrowserPreferences import com.kododake.aabrowser.databinding.ActivityMainBinding import com.kododake.aabrowser.ui.adapters.StartPageAdapter import com.kododake.aabrowser.ui.adapters.SlotItem import com.kododake.aabrowser.data.SiteIconCache import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class StartPageManager( private val activity: AppCompatActivity, private val binding: ActivityMainBinding, private val bookmarkManager: BookmarkManager, private val callbacks: StartPageCallbacks ) { interface StartPageCallbacks { fun onNavigateToUrl(url: String) fun onShowMenuOverlay() fun onHideMenuOverlay() fun onEnterFullscreen() fun onExitFullscreen() fun isInFullscreen(): Boolean fun getCurrentUrl(): String fun resolveThemeColor(attrRes: Int): Int fun updateNavigationButtons() fun updateConnectionSecurityIcon(url: String?) fun showMenuButtonTemporarily() fun loadUrlFromIntent(url: String) fun resolveReadableTextColor(bg: Int, pr: Int, fb: Int): Int } var isShowingStartPage: Boolean = false var isStartPagePhotoOnlyMode: Boolean = false private var loadedStartPageBackgroundUri: String? = null private var loadedStartPageBackgroundBitmap: Bitmap? = null private var cachedStartPageGradientSignature: Int = 0 private val sponsorsManager = SponsorsManager(activity, binding, object : SponsorsManager.SponsorsCallbacks { override fun loadUrlFromIntent(url: String) = callbacks.loadUrlFromIntent(url) override fun resolveThemeColor(attrRes: Int) = callbacks.resolveThemeColor(attrRes) }) private val startPageAdapter: StartPageAdapter by lazy { StartPageAdapter( bookmarkManager = bookmarkManager, onSlotClick = { url -> if (url.isEmpty()) { callbacks.onShowMenuOverlay() bookmarkManager.showBookmarkManager() } else { callbacks.loadUrlFromIntent(url) } }, onReordered = { newList -> syncBookmarksFromSlots(newList.map { it.url }) }, resolveThemeColor = callbacks::resolveThemeColor ) } private var backgroundLoadJob: Job? = null fun onDestroy() { backgroundLoadJob?.cancel() backgroundLoadJob = null loadedStartPageBackgroundBitmap?.recycle() loadedStartPageBackgroundBitmap = null loadedStartPageBackgroundUri = null } private fun setupRecyclerView() { if (binding.startPageQuickLinksContainer.adapter != null) { return } binding.startPageQuickLinksContainer.apply { layoutManager = GridLayoutManager(activity, 2) adapter = startPageAdapter } val touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0 ) { override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { startPageAdapter.onItemMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} }) touchHelper.attachToRecyclerView(binding.startPageQuickLinksContainer) } private fun syncBookmarksFromSlots(slotUrls: List) { slotUrls.forEachIndexed { index, url -> BrowserPreferences.setStartPageSlot(activity, index, url.takeIf { it.isNotEmpty() }) } val allBookmarks = BrowserPreferences.getBookmarks(activity).toMutableList() val nonSlotBookmarks = allBookmarks.filter { it !in slotUrls.filter { s -> s.isNotEmpty() } } val newOrder = mutableListOf() slotUrls.filter { it.isNotEmpty() }.forEach { newOrder.add(it) } newOrder.addAll(nonSlotBookmarks) BrowserPreferences.setBookmarks(activity, newOrder) bookmarkManager.refreshBookmarks() } fun showStartPage() { val homePageUrl = BrowserPreferences.getHomePageUrl(activity) if (!homePageUrl.isNullOrBlank()) { val message = activity.getString(R.string.start_page_disabled_by_home_page) Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() callbacks.loadUrlFromIntent(homePageUrl) return } if (callbacks.isInFullscreen()) { callbacks.onExitFullscreen() } isShowingStartPage = true isStartPagePhotoOnlyMode = false binding.startPageRoot.visibility = View.VISIBLE binding.pageTitle.text = activity.getString(R.string.start_page_title) binding.addressEdit.setText("") callbacks.updateConnectionSecurityIcon(null) refreshStartPage() applyStartPagePhotoOnlyMode() callbacks.updateNavigationButtons() callbacks.showMenuButtonTemporarily() } fun hideStartPage(currentPageTitle: String, currentUrl: String) { if (!isShowingStartPage && binding.startPageRoot.visibility != View.VISIBLE) { return } isShowingStartPage = false isStartPagePhotoOnlyMode = false applyStartPagePhotoOnlyMode() binding.startPageRoot.visibility = View.GONE binding.pageTitle.text = currentPageTitle.ifBlank { currentUrl.takeIf { it.isNotBlank() }?.let { bookmarkManager.displayLabelForUrl(it) }.orEmpty() } if (currentUrl.isNotBlank()) { if (binding.addressEdit.text?.toString() != currentUrl) { binding.addressEdit.setText(currentUrl) binding.addressEdit.setSelection(currentUrl.length) } } else { binding.addressEdit.setText("") } val iconUrl = currentUrl.takeIf { bookmarkManager.isActiveWebsiteUrl(it) } callbacks.updateConnectionSecurityIcon(iconUrl) callbacks.updateNavigationButtons() bookmarkManager.refreshBookmarks() } fun applyStartPagePhotoOnlyMode() { val visibility = if (isStartPagePhotoOnlyMode) View.GONE else View.VISIBLE binding.startPageScroll.visibility = visibility binding.startPageDimOverlay.visibility = visibility binding.buttonStartPagePhotoOnly.text = activity.getString( if (isStartPagePhotoOnlyMode) R.string.start_page_show_ui else R.string.start_page_photo_only ) if (!isStartPagePhotoOnlyMode && isShowingStartPage) { callbacks.showMenuButtonTemporarily() } } fun refreshStartPage() { setupRecyclerView() refreshStartPageQuickLinks() refreshStartPageBackground() refreshStartPageResumeButton() sponsorsManager.setupSponsorsSection() setupStartPageCardGlassBackground() } private fun setupStartPageCardGlassBackground() { val cardBg = callbacks.resolveThemeColor(com.google.android.material.R.attr.colorSurfaceContainerLow) val glassBg = androidx.core.graphics.ColorUtils.setAlphaComponent(cardBg, 120) binding.startPageCard.setCardBackgroundColor(glassBg) val outlineColor = callbacks.resolveThemeColor(com.google.android.material.R.attr.colorOutline) binding.startPageCard.strokeColor = androidx.core.graphics.ColorUtils.setAlphaComponent(outlineColor, 80) binding.startPageCard.strokeWidth = (1.5f * activity.resources.displayMetrics.density).toInt() binding.startPageSponsorsCard.setCardBackgroundColor(glassBg) binding.startPageSponsorsCard.strokeColor = androidx.core.graphics.ColorUtils.setAlphaComponent(outlineColor, 80) binding.startPageSponsorsCard.strokeWidth = (1.5f * activity.resources.displayMetrics.density).toInt() binding.startPageSponsorsListCard.setCardBackgroundColor(glassBg) binding.startPageSponsorsListCard.strokeColor = androidx.core.graphics.ColorUtils.setAlphaComponent(outlineColor, 80) binding.startPageSponsorsListCard.strokeWidth = (1.5f * activity.resources.displayMetrics.density).toInt() binding.startPageSponsorsHiddenPienCard.setCardBackgroundColor(glassBg) binding.startPageSponsorsHiddenPienCard.strokeColor = androidx.core.graphics.ColorUtils.setAlphaComponent(outlineColor, 80) binding.startPageSponsorsHiddenPienCard.strokeWidth = (1.5f * activity.resources.displayMetrics.density).toInt() } fun refreshStartPageBackground() { applyDynamicStartPageGradientBackground() val backgroundUri = BrowserPreferences.getStartPageBackgroundUri(activity) if (backgroundUri.isNullOrBlank()) { clearBackground() return } if (backgroundUri == loadedStartPageBackgroundUri && loadedStartPageBackgroundBitmap != null) { binding.startPageBackgroundImage.setImageBitmap(loadedStartPageBackgroundBitmap) binding.startPageBackgroundImage.visibility = View.VISIBLE return } backgroundLoadJob?.cancel() val uriToLoad = Uri.parse(backgroundUri) val metrics = activity.resources.displayMetrics val reqWidth = metrics.widthPixels.coerceAtLeast(1) val reqHeight = metrics.heightPixels.coerceAtLeast(1) backgroundLoadJob = activity.lifecycleScope.launch { val bitmap = withContext(Dispatchers.IO) { decodeSampledBitmapFromUri(uriToLoad, reqWidth, reqHeight) } loadedStartPageBackgroundBitmap?.recycle() loadedStartPageBackgroundBitmap = bitmap loadedStartPageBackgroundUri = if (bitmap != null) backgroundUri else null if (bitmap != null) { binding.startPageBackgroundImage.setImageBitmap(bitmap) binding.startPageBackgroundImage.visibility = View.VISIBLE } else { binding.startPageBackgroundImage.setImageBitmap(null) binding.startPageBackgroundImage.visibility = View.GONE } } } private fun clearBackground() { loadedStartPageBackgroundBitmap?.recycle() loadedStartPageBackgroundBitmap = null loadedStartPageBackgroundUri = null binding.startPageBackgroundImage.setImageBitmap(null) binding.startPageBackgroundImage.visibility = View.GONE } private fun applyDynamicStartPageGradientBackground() { val baseSurface = callbacks.resolveThemeColor(com.google.android.material.R.attr.colorSurface) val primaryContainer = callbacks.resolveThemeColor(com.google.android.material.R.attr.colorPrimaryContainer) val secondaryContainer = callbacks.resolveThemeColor(com.google.android.material.R.attr.colorSecondaryContainer) val tertiaryContainer = callbacks.resolveThemeColor(com.google.android.material.R.attr.colorTertiaryContainer) val signature = baseSurface xor primaryContainer xor secondaryContainer xor tertiaryContainer if (cachedStartPageGradientSignature == signature) { return } val linearStart = ColorUtils.blendARGB(baseSurface, secondaryContainer, 0.30f) val linearMid = ColorUtils.blendARGB(baseSurface, tertiaryContainer, 0.28f) val linearEnd = ColorUtils.blendARGB(baseSurface, primaryContainer, 0.30f) val baseLayer = GradientDrawable(GradientDrawable.Orientation.TL_BR, intArrayOf(linearStart, linearMid, linearEnd)).apply { gradientType = GradientDrawable.LINEAR_GRADIENT } val density = activity.resources.displayMetrics.density val blobs = arrayOf( createBlob(primaryContainer, tertiaryContainer, 0.45f, 460f * density, 0.18f, 0.22f, 170), createBlob(secondaryContainer, primaryContainer, 0.50f, 520f * density, 0.78f, 0.30f, 160), createBlob(tertiaryContainer, secondaryContainer, 0.42f, 540f * density, 0.55f, 0.82f, 150) ) binding.startPageRoot.background = LayerDrawable(arrayOf(baseLayer) + blobs) cachedStartPageGradientSignature = signature } private fun createBlob(c1: Int, c2: Int, blend: Float, radius: Float, x: Float, y: Float, alpha: Int): GradientDrawable { val color = ColorUtils.blendARGB(c1, c2, blend) return GradientDrawable().apply { shape = GradientDrawable.RECTANGLE gradientType = GradientDrawable.RADIAL_GRADIENT gradientRadius = radius setGradientCenter(x, y) colors = intArrayOf(ColorUtils.setAlphaComponent(color, alpha), Color.TRANSPARENT) } } private fun decodeSampledBitmapFromUri(uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? { val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } runCatching { activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } } if (options.outWidth <= 0 || options.outHeight <= 0) { return null } options.inSampleSize = calculateInSampleSize(options.outWidth, options.outHeight, reqWidth, reqHeight) options.inJustDecodeBounds = false options.inPreferredConfig = Bitmap.Config.RGB_565 return runCatching { activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } }.getOrNull() } private fun calculateInSampleSize(srcWidth: Int, srcHeight: Int, reqWidth: Int, reqHeight: Int): Int { var inSampleSize = 1 if (srcHeight > reqHeight || srcWidth > reqWidth) { val halfHeight = srcHeight / 2 val halfWidth = srcWidth / 2 while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2 } } return inSampleSize } fun refreshStartPageQuickLinks() { val slots = BrowserPreferences.getStartPageSlots(activity).map { url -> val cleanUrl = url ?: "" val hasIcon = if (cleanUrl.isNotEmpty()) SiteIconCache.getCachedIcon(activity, cleanUrl) != null else false SlotItem(cleanUrl, hasIcon) } startPageAdapter.submitList(slots) } fun refreshStartPageResumeButton() { binding.buttonStartPageResume.isVisible = !BrowserPreferences.getLastVisitedUrl(activity).isNullOrBlank() } fun handleStartPageBackgroundPicked(uri: Uri?) { if (uri == null) { return } if (activity.contentResolver.openInputStream(uri)?.use { true } != true) { Toast.makeText(activity, R.string.start_page_background_error, Toast.LENGTH_SHORT).show() return } runCatching { activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } val prev = BrowserPreferences.getStartPageBackgroundUri(activity) BrowserPreferences.setStartPageBackgroundUri(activity, uri.toString()) if (!prev.isNullOrBlank() && prev != uri.toString()) { runCatching { activity.contentResolver.releasePersistableUriPermission(Uri.parse(prev), Intent.FLAG_GRANT_READ_URI_PERMISSION) } } refreshStartPage() Toast.makeText(activity, R.string.start_page_background_set, Toast.LENGTH_SHORT).show() } fun clearStartPageBackground() { val prev = BrowserPreferences.getStartPageBackgroundUri(activity) if (prev == null) { return } runCatching { activity.contentResolver.releasePersistableUriPermission(Uri.parse(prev), Intent.FLAG_GRANT_READ_URI_PERMISSION) } BrowserPreferences.clearStartPageBackgroundUri(activity) refreshStartPage() Toast.makeText(activity, R.string.start_page_background_cleared, Toast.LENGTH_SHORT).show() } }