diff --git a/app/src/main/assets/template/tool-view.html b/app/src/main/assets/template/tool-view.html index 1a12873..7ff13b2 100644 --- a/app/src/main/assets/template/tool-view.html +++ b/app/src/main/assets/template/tool-view.html @@ -1,18 +1,18 @@ - - - - Preview - - - - -
-
- Loading... -
-
- + + + + Preview + + + + + +
+
+ Loading... +
+
+ diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreen.kt index 5636677..5a21f81 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreen.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreen.kt @@ -10,22 +10,21 @@ import android.util.Log import android.webkit.ConsoleMessage import android.webkit.ValueCallback import android.webkit.WebView +import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -59,12 +58,14 @@ internal fun ToolViewRoute( viewModel: ToolViewScreenViewModel = hiltViewModel(), onBackClick: () -> Unit ) { - val isPreview by viewModel.isPreview.collectAsStateWithLifecycle() val toolViewUiState by viewModel.toolViewUiState.collectAsStateWithLifecycle() + val webViewInstanceState by viewModel.webviewInstance.collectAsStateWithLifecycle() + val isPreview by viewModel.isPreview.collectAsStateWithLifecycle() ToolViewScreen( modifier = modifier, toolViewUiState = toolViewUiState, + webViewInstanceState = webViewInstanceState, isPreview = isPreview, onBackClick = onBackClick ) @@ -76,68 +77,79 @@ internal fun ToolViewRoute( internal fun ToolViewScreen( modifier: Modifier = Modifier, toolViewUiState: ToolViewUiState, + webViewInstanceState: WebViewInstanceState, isPreview: Boolean, onBackClick: () -> Unit ) { + + Column( + modifier + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + ) + ) + ) { + TopBar( + toolViewUiState = toolViewUiState, + isPreview = isPreview, + onBackClick = onBackClick + ) + Content( + toolViewUiState = toolViewUiState, + webViewInstanceState = webViewInstanceState + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + toolViewUiState: ToolViewUiState, + isPreview: Boolean, + onBackClick: () -> Unit +) = OxygenTopAppBar( + title = { + Text( + text = when (toolViewUiState) { + ToolViewUiState.Loading -> stringResource(R.string.core_loading) + ToolViewUiState.Error -> stringResource(R.string.feature_tools_can_not_open) + is ToolViewUiState.Success -> if (isPreview) stringResource( + R.string.feature_tool_view_preview_suffix, + toolViewUiState.toolName + ) else toolViewUiState.toolName + } + ) + }, + navigationIcon = OxygenIcons.Back, + navigationIconContentDescription = stringResource(R.string.core_back), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + onNavigationClick = onBackClick +) + +@Composable +private fun Content( + toolViewUiState: ToolViewUiState, + webViewInstanceState: WebViewInstanceState +) { val context = LocalContext.current var fileChooserCallback by remember { mutableStateOf>?>(null) } - val fileChooserLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> - fileChooserCallback?.onReceiveValue(arrayOf(uri)) - } ?: { - fileChooserCallback?.onReceiveValue(emptyArray()) - } - } else { - fileChooserCallback?.onReceiveValue(emptyArray()) - } + val fileChooserLauncher = rememberFileChooserLauncher(fileChooserCallback) + + val permissionLauncher = rememberPermissionLauncher() + + when (webViewInstanceState) { + WebViewInstanceState.Loading -> { + Indicator() } - val permissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - Permissions.continuation?.resume(it) - } - - Scaffold( - modifier = Modifier, - containerColor = Color.Transparent, - contentWindowInsets = WindowInsets(left = 0, top = 0, right = 0, bottom = 0) - ) { padding -> - Column( - modifier - .fillMaxWidth() - .padding(padding) - .consumeWindowInsets(padding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal - ) - ) - ) { - OxygenTopAppBar( - title = { - Text( - text = when (toolViewUiState) { - ToolViewUiState.Loading -> stringResource(R.string.core_loading) - ToolViewUiState.Error -> stringResource(R.string.feature_tools_can_not_open) - is ToolViewUiState.Success -> if (isPreview) stringResource( - R.string.feature_tool_view_preview_suffix, - toolViewUiState.toolName - ) else toolViewUiState.toolName - } - ) - }, - navigationIcon = OxygenIcons.Back, - navigationIconContentDescription = stringResource(R.string.core_back), - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent - ), - onNavigationClick = onBackClick - ) + is WebViewInstanceState.Success -> { when (toolViewUiState) { ToolViewUiState.Loading -> { Indicator() @@ -162,79 +174,18 @@ internal fun ToolViewScreen( .fillMaxSize() .imePadding(), state = webViewState, - chromeClient = remember { - object : AccompanistWebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - consoleMessage?.let { - Timber.tag("WebView").log( - priority = when (it.messageLevel()) { - ConsoleMessage.MessageLevel.TIP -> Log.VERBOSE - ConsoleMessage.MessageLevel.LOG -> Log.INFO - ConsoleMessage.MessageLevel.WARNING -> Log.WARN - ConsoleMessage.MessageLevel.ERROR -> Log.ERROR - else -> Log.DEBUG - }, - message = "${it.message()} (${it.lineNumber()})" - ) - return true - } - return false - } - - override fun onShowFileChooser( - webView: WebView?, - filePathCallback: ValueCallback>?, - fileChooserParams: FileChooserParams? - ): Boolean { - fileChooserCallback = filePathCallback - val intent = fileChooserParams?.createIntent() - ?: Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - fileChooserLauncher.launch( - Intent.createChooser( - intent, - fileChooserParams?.title ?: ResourcesUtils.getString( - context = context, - resId = R.string.core_file_select_one_text - ) - ) - ) - - return true - } - } + chromeClient = rememberChromeClient( + context = context, + fileChooserLauncher = fileChooserLauncher + ) { + fileChooserCallback = it }, - onCreated = { - it.settings.javaScriptEnabled = true - it.settings.domStorageEnabled = true - it.addJavascriptInterface( - NativeWebApi(context = context, webView = it, permissionLauncher), - "NativeApi" - ) - it.setDownloadListener { url, userAgent, _, mimetype, _ -> - if (!listOf("http://", "https://").any(url::startsWith)) { - it.evaluateJavascript( - "alert('${ - ResourcesUtils.getString( - context = context, - resId = R.string.core_can_only_download_http_https, - url - ) - }')" - ) {} - return@setDownloadListener - } - val request = DownloadManager.Request(Uri.parse(url)).apply { - addRequestHeader("User-Agent", userAgent) - setMimeType(mimetype) - setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - } - val downloadManager: DownloadManager = - context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - downloadManager.enqueue(request) - } + onCreated = initWebView( + context = context, + permissionLauncher = permissionLauncher + ), + factory = { + webViewInstanceState.webView } ) } @@ -242,3 +193,108 @@ internal fun ToolViewScreen( } } } + +@Composable +private fun rememberFileChooserLauncher(fileChooserCallback: ValueCallback>?) = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> + fileChooserCallback?.onReceiveValue(arrayOf(uri)) + } ?: { + fileChooserCallback?.onReceiveValue(emptyArray()) + } + } else { + fileChooserCallback?.onReceiveValue(emptyArray()) + } + } + +@Composable +private fun rememberPermissionLauncher() = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + Permissions.continuation?.resume(it) + } + +@SuppressLint("SetJavaScriptEnabled") +private fun initWebView( + context: Context, + permissionLauncher: ManagedActivityResultLauncher +) = { webview: WebView -> + webview.settings.javaScriptEnabled = true + webview.settings.domStorageEnabled = true + webview.addJavascriptInterface( + NativeWebApi(context = context, webView = webview, permissionLauncher), + "NativeApi" + ) + webview.setDownloadListener { url, userAgent, _, mimetype, _ -> + if (!listOf("http://", "https://").any(url::startsWith)) { + webview.evaluateJavascript( + "alert('${ + ResourcesUtils.getString( + context = context, + resId = R.string.core_can_only_download_http_https, + url + ) + }')" + ) {} + return@setDownloadListener + } + val request = DownloadManager.Request(Uri.parse(url)).apply { + addRequestHeader("User-Agent", userAgent) + setMimeType(mimetype) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + } + val downloadManager: DownloadManager = + context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + downloadManager.enqueue(request) + } +} + +@Composable +private fun rememberChromeClient( + context: Context, + fileChooserLauncher: ManagedActivityResultLauncher, + processCallback: (ValueCallback>?) -> Unit +) = remember { + object : AccompanistWebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + consoleMessage?.let { + Timber.tag("WebView").log( + priority = when (it.messageLevel()) { + ConsoleMessage.MessageLevel.TIP -> Log.VERBOSE + ConsoleMessage.MessageLevel.LOG -> Log.INFO + ConsoleMessage.MessageLevel.WARNING -> Log.WARN + ConsoleMessage.MessageLevel.ERROR -> Log.ERROR + else -> Log.DEBUG + }, + message = "${it.message()} (${it.lineNumber()})" + ) + return true + } + return false + } + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams? + ): Boolean { + processCallback(filePathCallback) + val intent = fileChooserParams?.createIntent() + ?: Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + fileChooserLauncher.launch( + Intent.createChooser( + intent, + fileChooserParams?.title ?: ResourcesUtils.getString( + context = context, + resId = R.string.core_file_select_one_text + ) + ) + ) + + return true + } + } +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreenViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreenViewModel.kt index abaad4e..8260653 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreenViewModel.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/view/ToolViewScreenViewModel.kt @@ -1,9 +1,12 @@ package top.fatweb.oxygen.toolbox.ui.view +import android.content.Context +import android.webkit.WebView import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow @@ -27,6 +30,7 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class ToolViewScreenViewModel @Inject constructor( + @ApplicationContext context: Context, storeRepository: StoreRepository, toolRepository: ToolRepository, savedStateHandle: SavedStateHandle @@ -55,6 +59,15 @@ class ToolViewScreenViewModel @Inject constructor( initialValue = ToolViewUiState.Loading, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds) ) + + val webviewInstance = flow { + val webviewInstance = WebView(context) + emit(WebViewInstanceState.Success(webviewInstance)) + }.stateIn( + viewModelScope, + initialValue = WebViewInstanceState.Loading, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds) + ) } private fun toolViewUiState( @@ -141,12 +154,22 @@ sealed interface ToolViewUiState { data object Loading : ToolViewUiState } +sealed interface WebViewInstanceState { + data class Success( + val webView: WebView + ) : WebViewInstanceState + + data object Loading : WebViewInstanceState +} + @OptIn(ExperimentalEncodingApi::class) private fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String { val dist = Base64.decodeToStringWithZip(distBase64) val base = Base64.decodeToStringWithZip(baseBase64) - return toolViewTemplate.replace("{{replace_code}}", "$dist\n$base") + return toolViewTemplate + .replace(oldValue = "{{replace_dict_code}}", newValue = dist) + .replace(oldValue = "{{replace_base_code}}", newValue = base) } private const val IS_PREVIEW = "IS_PREVIEW"