1 /* <lambda>null2 * Copyright (C) 2025 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.virtualization.terminal 17 18 import android.content.Intent 19 import android.graphics.Bitmap 20 import android.net.http.SslError 21 import android.os.Bundle 22 import android.util.Log 23 import android.view.LayoutInflater 24 import android.view.View 25 import android.view.ViewGroup 26 import android.webkit.ClientCertRequest 27 import android.webkit.JavascriptInterface 28 import android.webkit.SslErrorHandler 29 import android.webkit.WebChromeClient 30 import android.webkit.WebResourceError 31 import android.webkit.WebResourceRequest 32 import android.webkit.WebSettings 33 import android.webkit.WebView 34 import android.webkit.WebViewClient 35 import android.widget.TextView 36 import androidx.fragment.app.Fragment 37 import androidx.fragment.app.activityViewModels 38 import com.android.system.virtualmachine.flags.Flags.terminalGuiSupport 39 import com.android.virtualization.terminal.CertificateUtils.createOrGetKey 40 import com.android.virtualization.terminal.CertificateUtils.writeCertificateToFile 41 import java.security.PrivateKey 42 import java.security.cert.X509Certificate 43 44 class TerminalTabFragment() : Fragment() { 45 private lateinit var terminalView: TerminalView 46 private lateinit var bootProgressView: View 47 private lateinit var id: String 48 private var certificates: Array<X509Certificate>? = null 49 private var privateKey: PrivateKey? = null 50 private val terminalViewModel: TerminalViewModel by activityViewModels() 51 52 override fun onCreateView( 53 inflater: LayoutInflater, 54 container: ViewGroup?, 55 savedInstanceState: Bundle?, 56 ): View { 57 val view = inflater.inflate(R.layout.fragment_terminal_tab, container, false) 58 arguments?.let { id = it.getString("id")!! } 59 return view 60 } 61 62 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 63 super.onViewCreated(view, savedInstanceState) 64 terminalView = view.findViewById(R.id.webview) 65 bootProgressView = view.findViewById(R.id.boot_progress) 66 initializeWebView() 67 readClientCertificate() 68 69 terminalView.webViewClient = TerminalWebViewClient() 70 71 if (savedInstanceState != null) { 72 terminalView.restoreState(savedInstanceState) 73 } else { 74 (activity as MainActivity).connectToTerminalService(terminalView) 75 } 76 } 77 78 override fun onSaveInstanceState(outState: Bundle) { 79 super.onSaveInstanceState(outState) 80 terminalView.saveState(outState) 81 } 82 83 override fun onResume() { 84 super.onResume() 85 updateFocus() 86 } 87 88 private fun initializeWebView() { 89 terminalView.settings.databaseEnabled = true 90 terminalView.settings.domStorageEnabled = true 91 terminalView.settings.javaScriptEnabled = true 92 terminalView.settings.cacheMode = WebSettings.LOAD_DEFAULT 93 94 terminalView.webChromeClient = TerminalWebChromeClient() 95 terminalView.webViewClient = TerminalWebViewClient() 96 terminalView.addJavascriptInterface(TerminalViewInterface(context!!), "TerminalApp") 97 98 (activity as MainActivity).modifierKeysController.addTerminalView(terminalView) 99 terminalViewModel.terminalViews.add(terminalView) 100 } 101 102 private inner class TerminalWebChromeClient : WebChromeClient() { 103 override fun onReceivedTitle(view: WebView?, title: String?) { 104 super.onReceivedTitle(view, title) 105 title?.let { originalTitle -> 106 val ttydSuffix = " | login -f droid (localhost)" 107 val displayedTitle = 108 if (originalTitle.endsWith(ttydSuffix)) { 109 // When the session is created. The format of the title will be 110 // 'droid@localhost: ~ | login -f droid (localhost)'. 111 originalTitle.dropLast(ttydSuffix.length) 112 } else { 113 originalTitle 114 } 115 116 terminalViewModel.terminalTabs[id] 117 ?.customView 118 ?.findViewById<TextView>(R.id.tab_title) 119 ?.text = displayedTitle 120 } 121 } 122 } 123 124 inner class TerminalViewInterface(private val mContext: android.content.Context) { 125 @JavascriptInterface 126 fun closeTab() { 127 if (terminalViewModel.terminalTabs.containsKey(id)) { 128 if (activity != null) { 129 activity?.runOnUiThread { 130 val mainActivity = (activity as MainActivity) 131 mainActivity.closeTab(terminalViewModel.terminalTabs[id]!!) 132 } 133 } 134 } 135 } 136 } 137 138 private inner class TerminalWebViewClient : WebViewClient() { 139 private var loadFailed = false 140 private var requestId: Long = 0 141 142 override fun shouldOverrideUrlLoading( 143 view: WebView?, 144 request: WebResourceRequest?, 145 ): Boolean { 146 val intent = Intent(Intent.ACTION_VIEW, request?.url) 147 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 148 startActivity(intent) 149 return true 150 } 151 152 override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { 153 loadFailed = false 154 } 155 156 override fun onReceivedError( 157 view: WebView, 158 request: WebResourceRequest, 159 error: WebResourceError, 160 ) { 161 loadFailed = true 162 when (error.getErrorCode()) { 163 ERROR_CONNECT, 164 ERROR_HOST_LOOKUP, 165 ERROR_FAILED_SSL_HANDSHAKE, 166 ERROR_TIMEOUT -> { 167 view.reload() 168 return 169 } 170 171 else -> { 172 val url: String? = request.getUrl().toString() 173 val msg = error.getDescription() 174 Log.e(MainActivity.TAG, "Failed to load $url: $msg") 175 } 176 } 177 } 178 179 override fun onPageFinished(view: WebView, url: String?) { 180 if (loadFailed) { 181 return 182 } 183 184 requestId++ 185 view.postVisualStateCallback( 186 requestId, 187 object : WebView.VisualStateCallback() { 188 override fun onComplete(completedRequestId: Long) { 189 if (completedRequestId == requestId) { 190 bootProgressView.visibility = View.GONE 191 terminalView.visibility = View.VISIBLE 192 terminalView.mapTouchToMouseEvent() 193 terminalView.applyTerminalDisconnectCallback() 194 updateMainActivity() 195 updateFocus() 196 } 197 } 198 }, 199 ) 200 } 201 202 override fun onReceivedClientCertRequest(view: WebView?, request: ClientCertRequest) { 203 if (privateKey != null && certificates != null) { 204 request.proceed(privateKey, certificates) 205 return 206 } 207 super.onReceivedClientCertRequest(view, request) 208 } 209 210 override fun onReceivedSslError( 211 view: WebView?, 212 handler: SslErrorHandler, 213 error: SslError?, 214 ) { 215 // ttyd uses self-signed certificate 216 handler.proceed() 217 } 218 } 219 220 private fun updateMainActivity() { 221 val mainActivity = activity as MainActivity ?: return 222 if (terminalGuiSupport()) { 223 mainActivity.displayMenu!!.visibility = View.VISIBLE 224 mainActivity.displayMenu!!.isEnabled = true 225 } 226 mainActivity.tabAddButton!!.isEnabled = true 227 mainActivity.bootCompleted.open() 228 } 229 230 private fun readClientCertificate() { 231 val pke = createOrGetKey() 232 writeCertificateToFile(activity!!, pke.certificate) 233 privateKey = pke.privateKey 234 certificates = arrayOf<X509Certificate>(pke.certificate as X509Certificate) 235 } 236 237 private fun updateFocus() { 238 if (terminalViewModel.selectedTabViewId == id) { 239 terminalView.requestFocus() 240 } 241 } 242 243 companion object { 244 const val TAG: String = "VmTerminalApp" 245 } 246 247 override fun onDestroy() { 248 terminalView.terminalClose() 249 terminalViewModel.terminalViews.remove(terminalView) 250 super.onDestroy() 251 } 252 } 253