• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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