1 /* <lambda>null2 * Copyright (C) 2024 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.app.ForegroundServiceStartNotAllowedException 19 import android.app.Notification 20 import android.app.PendingIntent 21 import android.content.Context 22 import android.content.Intent 23 import android.content.pm.ActivityInfo 24 import android.content.res.Configuration 25 import android.graphics.drawable.Icon 26 import android.graphics.fonts.FontStyle 27 import android.media.MediaScannerConnection 28 import android.net.Uri 29 import android.os.Build 30 import android.os.Bundle 31 import android.os.ConditionVariable 32 import android.os.Environment 33 import android.os.SystemProperties 34 import android.provider.Settings 35 import android.util.DisplayMetrics 36 import android.util.Log 37 import android.view.KeyEvent 38 import android.view.View 39 import android.view.ViewGroup 40 import android.view.WindowManager 41 import android.view.accessibility.AccessibilityManager 42 import android.widget.Button 43 import android.widget.HorizontalScrollView 44 import android.widget.RelativeLayout 45 import androidx.activity.result.ActivityResult 46 import androidx.activity.result.ActivityResultCallback 47 import androidx.activity.result.ActivityResultLauncher 48 import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult 49 import androidx.activity.viewModels 50 import androidx.viewpager2.widget.ViewPager2 51 import com.android.internal.annotations.VisibleForTesting 52 import com.android.microdroid.test.common.DeviceProperties 53 import com.android.system.virtualmachine.flags.Flags 54 import com.android.virtualization.terminal.ErrorActivity.Companion.start 55 import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback 56 import com.google.android.material.tabs.TabLayout 57 import com.google.android.material.tabs.TabLayoutMediator 58 import java.net.MalformedURLException 59 import java.net.URL 60 import java.util.concurrent.CompletableFuture 61 import java.util.concurrent.ExecutorService 62 import java.util.concurrent.Executors 63 64 public class MainActivity : 65 BaseActivity(), 66 VmLauncherServiceCallback, 67 AccessibilityManager.AccessibilityStateChangeListener { 68 var displayMenu: Button? = null 69 var tabAddButton: Button? = null 70 val bootCompleted = ConditionVariable() 71 lateinit var modifierKeysController: ModifierKeysController 72 private lateinit var tabScrollView: HorizontalScrollView 73 private lateinit var executorService: ExecutorService 74 private lateinit var image: InstalledImage 75 private lateinit var accessibilityManager: AccessibilityManager 76 private lateinit var manageExternalStorageActivityResultLauncher: ActivityResultLauncher<Intent> 77 private lateinit var viewPager: ViewPager2 78 private lateinit var tabLayout: TabLayout 79 private lateinit var terminalTabAdapter: TerminalTabAdapter 80 private val terminalInfo = CompletableFuture<TerminalInfo>() 81 private val terminalViewModel: TerminalViewModel by viewModels() 82 private var isVmRunning = false 83 84 override fun onCreate(savedInstanceState: Bundle?) { 85 super.onCreate(savedInstanceState) 86 lockOrientationIfNecessary() 87 88 image = InstalledImage.getDefault(this) 89 90 val launchInstaller = installIfNecessary() 91 92 initializeUi() 93 94 accessibilityManager = 95 getSystemService<AccessibilityManager>(AccessibilityManager::class.java) 96 accessibilityManager.addAccessibilityStateChangeListener(this) 97 98 manageExternalStorageActivityResultLauncher = 99 registerForActivityResult<Intent, ActivityResult>( 100 StartActivityForResult(), 101 ActivityResultCallback { startVm() }, 102 ) 103 executorService = 104 Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext)) 105 106 // if installer is launched, it will be handled in onActivityResult 107 if (!launchInstaller) { 108 if (image.isOlderThanCurrentVersion()) { 109 val intent = Intent(this, UpgradeActivity::class.java) 110 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) 111 startActivity(intent) 112 // Explicitly finish to make sure that user can't go back from ErrorActivity. 113 finish() 114 } else if (!Environment.isExternalStorageManager()) { 115 requestStoragePermissions(this, manageExternalStorageActivityResultLauncher) 116 } else { 117 startVm() 118 } 119 } 120 } 121 122 private fun initializeUi() { 123 setContentView(R.layout.activity_headless) 124 tabLayout = findViewById<TabLayout>(R.id.tab_layout) 125 displayMenu = findViewById<Button>(R.id.display_button) 126 tabAddButton = findViewById<Button>(R.id.tab_add_button) 127 tabScrollView = findViewById<HorizontalScrollView>(R.id.tab_scrollview) 128 val modifierKeysContainerView = 129 findViewById<RelativeLayout>(R.id.modifier_keys_container) as ViewGroup 130 131 findViewById<Button>(R.id.settings_button).setOnClickListener { 132 val intent = Intent(this, SettingsActivity::class.java) 133 this.startActivity(intent) 134 } 135 136 displayMenu?.also { 137 it.visibility = if (Flags.terminalGuiSupport()) View.VISIBLE else View.GONE 138 it.setEnabled(false) 139 if (Flags.terminalGuiSupport()) { 140 it.setOnClickListener { 141 val intent = Intent(this, DisplayActivity::class.java) 142 intent.flags = 143 intent.flags or 144 Intent.FLAG_ACTIVITY_NEW_TASK or 145 Intent.FLAG_ACTIVITY_CLEAR_TASK 146 this.startActivity(intent) 147 } 148 } 149 } 150 151 modifierKeysController = ModifierKeysController(this, modifierKeysContainerView) 152 153 terminalTabAdapter = TerminalTabAdapter(this) 154 viewPager = findViewById(R.id.pager) 155 viewPager.adapter = terminalTabAdapter 156 viewPager.isUserInputEnabled = false 157 viewPager.offscreenPageLimit = 2 158 159 TabLayoutMediator(tabLayout, viewPager, false, false) { _: TabLayout.Tab?, _: Int -> } 160 .attach() 161 162 tabLayout.addOnTabSelectedListener( 163 object : TabLayout.OnTabSelectedListener { 164 override fun onTabSelected(tab: TabLayout.Tab?) { 165 tab?.position?.let { 166 terminalViewModel.selectedTabViewId = terminalTabAdapter.tabs[it].id 167 } 168 } 169 170 override fun onTabUnselected(tab: TabLayout.Tab?) {} 171 172 override fun onTabReselected(tab: TabLayout.Tab?) {} 173 } 174 ) 175 176 addTerminalTab() 177 178 tabAddButton?.setOnClickListener { addTerminalTab() } 179 } 180 181 private fun addTerminalTab() { 182 val tab = tabLayout.newTab() 183 tab.setCustomView(R.layout.tabitem_terminal) 184 viewPager.offscreenPageLimit += 1 185 val tabId = terminalTabAdapter.addTab() 186 terminalViewModel.selectedTabViewId = tabId 187 terminalViewModel.terminalTabs[tabId] = tab 188 tab.customView!! 189 .findViewById<Button>(R.id.tab_close_button) 190 .setOnClickListener(View.OnClickListener { _: View? -> closeTab(tab) }) 191 // Add and select the tab 192 tabLayout.addTab(tab, true) 193 } 194 195 fun closeTab(tab: TabLayout.Tab) { 196 if (terminalTabAdapter.tabs.size == 1) { 197 finish() 198 } 199 viewPager.offscreenPageLimit -= 1 200 terminalTabAdapter.deleteTab(tab.position) 201 tabLayout.removeTab(tab) 202 } 203 204 private fun lockOrientationIfNecessary() { 205 val hasHwQwertyKeyboard = resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY 206 if (hasHwQwertyKeyboard) { 207 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) 208 } else if (resources.getBoolean(R.bool.terminal_portrait_only)) { 209 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) 210 } 211 } 212 213 override fun onConfigurationChanged(newConfig: Configuration) { 214 super.onConfigurationChanged(newConfig) 215 lockOrientationIfNecessary() 216 modifierKeysController.update() 217 } 218 219 override fun dispatchKeyEvent(event: KeyEvent): Boolean { 220 if (Build.isDebuggable() && event.keyCode == KeyEvent.KEYCODE_UNKNOWN) { 221 if (event.action == KeyEvent.ACTION_UP) { 222 ErrorActivity.start(this, Exception("Debug: KeyEvent.KEYCODE_UNKNOWN")) 223 } 224 return true 225 } 226 return super.dispatchKeyEvent(event) 227 } 228 229 private fun requestStoragePermissions( 230 context: Context, 231 activityResultLauncher: ActivityResultLauncher<Intent>, 232 ) { 233 val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) 234 val uri = Uri.fromParts("package", context.getPackageName(), null) 235 intent.setData(uri) 236 activityResultLauncher.launch(intent) 237 } 238 239 override fun onPause() { 240 super.onPause() 241 MediaScannerConnection.scanFile( 242 this, 243 arrayOf("/storage/emulated/${userId}/Download"), 244 null /* mimeTypes */, 245 null, /* callback */ 246 ) 247 } 248 249 private fun getTerminalServiceUrl(ipAddress: String?, port: Int): URL? { 250 val config = resources.configuration 251 // TODO: Always enable screenReaderMode (b/395845063) 252 val query = 253 ("?fontSize=" + 254 (config.fontScale * FONT_SIZE_DEFAULT).toInt() + 255 "&fontWeight=" + 256 (FontStyle.FONT_WEIGHT_NORMAL + config.fontWeightAdjustment) + 257 "&fontWeightBold=" + 258 (FontStyle.FONT_WEIGHT_BOLD + config.fontWeightAdjustment) + 259 "&screenReaderMode=" + 260 accessibilityManager.isEnabled) 261 262 try { 263 return URL("https", ipAddress, port, "/$query") 264 } catch (e: MalformedURLException) { 265 // this cannot happen 266 return null 267 } 268 } 269 270 fun connectToTerminalService(terminalView: TerminalView) { 271 terminalInfo.thenAcceptAsync( 272 { info -> 273 val url = getTerminalServiceUrl(info.ipAddress, info.port) 274 runOnUiThread({ terminalView.loadUrl(url.toString()) }) 275 }, 276 executorService, 277 ) 278 } 279 280 override fun onDestroy() { 281 executorService.shutdown() 282 getSystemService<AccessibilityManager>(AccessibilityManager::class.java) 283 .removeAccessibilityStateChangeListener(this) 284 if (isVmRunning) { 285 val intent = VmLauncherService.getIntentForShutdown(this, this) 286 startService(intent) 287 } 288 super.onDestroy() 289 } 290 291 override fun onVmStart() { 292 Log.i(TAG, "onVmStart()") 293 isVmRunning = true 294 } 295 296 override fun onTerminalAvailable(info: TerminalInfo) { 297 terminalInfo.complete(info) 298 } 299 300 override fun onVmStop() { 301 Log.i(TAG, "onVmStop()") 302 isVmRunning = false 303 finish() 304 } 305 306 override fun onVmError() { 307 Log.i(TAG, "onVmError()") 308 isVmRunning = false 309 // TODO: error cause is too simple. 310 ErrorActivity.start(this, Exception("onVmError")) 311 } 312 313 override fun onAccessibilityStateChanged(enabled: Boolean) { 314 terminalViewModel.terminalViews.forEach { terminalView -> 315 connectToTerminalService(terminalView) 316 } 317 } 318 319 private val installerLauncher = 320 registerForActivityResult(StartActivityForResult()) { result -> 321 val resultCode = result.resultCode 322 if (resultCode != RESULT_OK) { 323 Log.e(TAG, "Failed to start VM. Installer returned error.") 324 finish() 325 } 326 if (!Environment.isExternalStorageManager()) { 327 requestStoragePermissions(this, manageExternalStorageActivityResultLauncher) 328 } else { 329 startVm() 330 } 331 } 332 333 private fun installIfNecessary(): Boolean { 334 // If payload from external storage exists(only for debuggable build) or there is no 335 // installed image, launch installer activity. 336 if (!image.isInstalled()) { 337 val intent = Intent(this, InstallerActivity::class.java) 338 installerLauncher.launch(intent) 339 return true 340 } 341 return false 342 } 343 344 private fun startVm() { 345 val image = InstalledImage.getDefault(this) 346 if (!image.isInstalled()) { 347 return 348 } 349 350 val tapIntent = Intent(this, MainActivity::class.java) 351 tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) 352 val tapPendingIntent = 353 PendingIntent.getActivity(this, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE) 354 355 val settingsIntent = Intent(this, SettingsActivity::class.java) 356 settingsIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) 357 val settingsPendingIntent = 358 PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE) 359 360 val stopIntent = VmLauncherService.getIntentForShutdown(this, this) 361 val stopPendingIntent = 362 PendingIntent.getService( 363 this, 364 0, 365 stopIntent, 366 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 367 ) 368 val icon = Icon.createWithResource(resources, R.drawable.ic_launcher_foreground) 369 val notification: Notification = 370 Notification.Builder(this, Application.CHANNEL_LONG_RUNNING_ID) 371 .setSilent(true) 372 .setSmallIcon(R.drawable.ic_launcher_foreground) 373 .setContentTitle(resources.getString(R.string.service_notification_title)) 374 .setContentText(resources.getString(R.string.service_notification_content)) 375 .setContentIntent(tapPendingIntent) 376 .setOngoing(true) 377 .addAction( 378 Notification.Action.Builder( 379 icon, 380 resources.getString(R.string.service_notification_settings), 381 settingsPendingIntent, 382 ) 383 .build() 384 ) 385 .addAction( 386 Notification.Action.Builder( 387 icon, 388 resources.getString(R.string.service_notification_quit_action), 389 stopPendingIntent, 390 ) 391 .build() 392 ) 393 .build() 394 395 val diskSize = intent.getLongExtra(EXTRA_DISK_SIZE, image.getApparentSize()) 396 397 val intent = 398 VmLauncherService.getIntentForStart( 399 this, 400 this, 401 notification, 402 getDisplayInfo(), 403 diskSize, 404 ) 405 try { 406 startForegroundService(intent) 407 } catch (e: ForegroundServiceStartNotAllowedException) { 408 Log.e(TAG, "Failed to start VM", e) 409 finish() 410 } 411 } 412 413 @VisibleForTesting 414 public fun waitForBootCompleted(timeoutMillis: Long): Boolean { 415 return bootCompleted.block(timeoutMillis) 416 } 417 418 companion object { 419 const val TAG: String = "VmTerminalApp" 420 const val PREFIX: String = "com.android.virtualization.terminal." 421 const val EXTRA_DISK_SIZE: String = PREFIX + "EXTRA_DISK_SIZE" 422 private val TERMINAL_CONNECTION_TIMEOUT_MS: Int 423 private const val REQUEST_CODE_INSTALLER = 0x33 424 private const val FONT_SIZE_DEFAULT = 13 425 426 init { 427 val prop = 428 DeviceProperties.create( 429 DeviceProperties.PropertyGetter { key: String -> SystemProperties.get(key) } 430 ) 431 TERMINAL_CONNECTION_TIMEOUT_MS = 432 if (prop.isCuttlefish() || prop.isGoldfish()) { 433 180000 // 3 minutes 434 } else { 435 20000 // 20 sec 436 } 437 } 438 } 439 440 fun getDisplayInfo(): DisplayInfo { 441 val wm = getSystemService<WindowManager>(WindowManager::class.java) 442 val metrics = wm.currentWindowMetrics 443 val dispBounds = metrics.bounds 444 445 // For now, display activity runs as landscape mode 446 val height = Math.min(dispBounds.right, dispBounds.bottom) 447 val width = Math.max(dispBounds.right, dispBounds.bottom) 448 var dpi = (DisplayMetrics.DENSITY_DEFAULT * metrics.density).toInt() 449 var refreshRate = display.refreshRate.toInt() 450 451 return DisplayInfo(width, height, dpi, refreshRate) 452 } 453 } 454