Convert Installer* and BaseActivity to kotlin
Bug: 383243644
Test: installing
Change-Id: I0d576cf59d84fa449bdca903c3ef439b4bc5475c
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java
deleted file mode 100644
index aeae5dd..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import android.Manifest;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.content.pm.PackageManager;
-import android.os.Bundle;
-
-import androidx.appcompat.app.AppCompatActivity;
-
-public abstract class BaseActivity extends AppCompatActivity {
- private static final int POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- NotificationManager notificationManager = getSystemService(NotificationManager.class);
- if (notificationManager.getNotificationChannel(this.getPackageName()) == null) {
- NotificationChannel channel =
- new NotificationChannel(
- this.getPackageName(),
- getString(R.string.app_name),
- NotificationManager.IMPORTANCE_HIGH);
- notificationManager.createNotificationChannel(channel);
- }
-
- if (!(this instanceof ErrorActivity)) {
- Thread currentThread = Thread.currentThread();
- if (!(currentThread.getUncaughtExceptionHandler()
- instanceof TerminalExceptionHandler)) {
- currentThread.setUncaughtExceptionHandler(
- new TerminalExceptionHandler(getApplicationContext()));
- }
- }
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- if (getApplicationContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
- != PackageManager.PERMISSION_GRANTED) {
- requestPermissions(
- new String[] {Manifest.permission.POST_NOTIFICATIONS},
- POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE);
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
new file mode 100644
index 0000000..e7ac8d9
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.pm.PackageManager
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+
+abstract class BaseActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val notificationManager =
+ getSystemService<NotificationManager>(NotificationManager::class.java)
+ if (notificationManager.getNotificationChannel(this.packageName) == null) {
+ val channel =
+ NotificationChannel(
+ this.packageName,
+ getString(R.string.app_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ )
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ if (this !is ErrorActivity) {
+ val currentThread = Thread.currentThread()
+ if (currentThread.uncaughtExceptionHandler !is TerminalExceptionHandler) {
+ currentThread.uncaughtExceptionHandler =
+ TerminalExceptionHandler(applicationContext)
+ }
+ }
+ }
+
+ public override fun onResume() {
+ super.onResume()
+
+ if (
+ applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ requestPermissions(
+ arrayOf<String>(Manifest.permission.POST_NOTIFICATIONS),
+ POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE,
+ )
+ }
+ }
+
+ companion object {
+ private const val POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
deleted file mode 100644
index 1c62572..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ /dev/null
@@ -1,314 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.annotation.MainThread;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.ConditionVariable;
-import android.os.FileUtils;
-import android.os.IBinder;
-import android.os.RemoteException;
-import android.text.format.Formatter;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.View;
-import android.widget.CheckBox;
-import android.widget.TextView;
-
-import com.android.internal.annotations.VisibleForTesting;
-
-import com.google.android.material.progressindicator.LinearProgressIndicator;
-import com.google.android.material.snackbar.Snackbar;
-
-import java.io.IOException;
-import java.lang.ref.WeakReference;
-
-public class InstallerActivity extends BaseActivity {
- private static final long ESTIMATED_IMG_SIZE_BYTES = FileUtils.parseSize("550MB");
-
- private CheckBox mWaitForWifiCheckbox;
- private TextView mInstallButton;
-
- private IInstallerService mService;
- private ServiceConnection mInstallerServiceConnection;
- private InstallProgressListener mInstallProgressListener;
- private boolean mInstallRequested;
- private ConditionVariable mInstallCompleted = new ConditionVariable();
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setResult(RESULT_CANCELED);
-
- mInstallProgressListener = new InstallProgressListener(this);
-
- setContentView(R.layout.activity_installer);
- updateSizeEstimation(ESTIMATED_IMG_SIZE_BYTES);
- measureImageSizeAndUpdateDescription();
-
- mWaitForWifiCheckbox = (CheckBox) findViewById(R.id.installer_wait_for_wifi_checkbox);
- mInstallButton = (TextView) findViewById(R.id.installer_install_button);
-
- mInstallButton.setOnClickListener(
- (event) -> {
- requestInstall();
- });
-
- Intent intent = new Intent(this, InstallerService.class);
- mInstallerServiceConnection = new InstallerServiceConnection(this);
- if (!bindService(intent, mInstallerServiceConnection, Context.BIND_AUTO_CREATE)) {
- handleInternalError(new Exception("Failed to connect to installer service"));
- }
- }
-
- private void updateSizeEstimation(long est) {
- String desc =
- getString(
- R.string.installer_desc_text_format,
- Formatter.formatShortFileSize(this, est));
- runOnUiThread(
- () -> {
- TextView view = (TextView) findViewById(R.id.installer_desc);
- view.setText(desc);
- });
- }
-
- private void measureImageSizeAndUpdateDescription() {
- new Thread(
- () -> {
- long est;
- try {
- est = ImageArchive.getDefault().getSize();
- } catch (IOException e) {
- Log.w(TAG, "Failed to measure image size.", e);
- return;
- }
- updateSizeEstimation(est);
- })
- .start();
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- if (Build.isDebuggable() && ImageArchive.fromSdCard().exists()) {
- showSnackbar("Auto installing", Snackbar.LENGTH_LONG);
- requestInstall();
- }
- }
-
- @Override
- public void onDestroy() {
- if (mInstallerServiceConnection != null) {
- unbindService(mInstallerServiceConnection);
- mInstallerServiceConnection = null;
- }
-
- super.onDestroy();
- }
-
- @Override
- public boolean onKeyUp(int keyCode, KeyEvent event) {
- if (keyCode == KeyEvent.KEYCODE_BUTTON_START) {
- requestInstall();
- return true;
- }
- return super.onKeyUp(keyCode, event);
- }
-
- @VisibleForTesting
- public boolean waitForInstallCompleted(long timeoutMillis) {
- return mInstallCompleted.block(timeoutMillis);
- }
-
- private void showSnackbar(String message, int length) {
- Snackbar snackbar = Snackbar.make(findViewById(android.R.id.content), message, length);
- snackbar.setAnchorView(mWaitForWifiCheckbox);
- snackbar.show();
- }
-
- public void handleInternalError(Exception e) {
- if (Build.isDebuggable()) {
- showSnackbar(
- e.getMessage() + ". File a bugreport to go/ferrochrome-bug",
- Snackbar.LENGTH_INDEFINITE);
- }
- Log.e(TAG, "Internal error", e);
- finishWithResult(RESULT_CANCELED);
- }
-
- private void finishWithResult(int resultCode) {
- if (resultCode == RESULT_OK) {
- mInstallCompleted.open();
- }
- setResult(resultCode);
- finish();
- }
-
- private void setInstallEnabled(boolean enable) {
- mInstallButton.setEnabled(enable);
- mWaitForWifiCheckbox.setEnabled(enable);
- LinearProgressIndicator progressBar = findViewById(R.id.installer_progress);
- if (enable) {
- progressBar.setVisibility(View.INVISIBLE);
- } else {
- progressBar.setVisibility(View.VISIBLE);
- }
-
- int resId =
- enable
- ? R.string.installer_install_button_enabled_text
- : R.string.installer_install_button_disabled_text;
- mInstallButton.setText(getString(resId));
- }
-
- @MainThread
- private void requestInstall() {
- setInstallEnabled(/* enable= */ false);
-
- if (mService != null) {
- try {
- mService.requestInstall(mWaitForWifiCheckbox.isChecked());
- } catch (RemoteException e) {
- handleInternalError(e);
- }
- } else {
- Log.d(TAG, "requestInstall() is called, but not yet connected");
- mInstallRequested = true;
- }
- }
-
- @MainThread
- public void handleInstallerServiceConnected() {
- try {
- mService.setProgressListener(mInstallProgressListener);
- if (mService.isInstalled()) {
- // Finishing this activity will trigger MainActivity::onResume(),
- // and VM will be started from there.
- finishWithResult(RESULT_OK);
- return;
- }
-
- if (mInstallRequested) {
- requestInstall();
- } else if (mService.isInstalling()) {
- setInstallEnabled(false);
- }
- } catch (RemoteException e) {
- handleInternalError(e);
- }
- }
-
- @MainThread
- public void handleInstallerServiceDisconnected() {
- handleInternalError(new Exception("InstallerService is destroyed while in use"));
- }
-
- @MainThread
- private void handleInstallError(String displayText) {
- showSnackbar(displayText, Snackbar.LENGTH_LONG);
- setInstallEnabled(true);
- }
-
- private static class InstallProgressListener extends IInstallProgressListener.Stub {
- private final WeakReference<InstallerActivity> mActivity;
-
- InstallProgressListener(InstallerActivity activity) {
- mActivity = new WeakReference<>(activity);
- }
-
- @Override
- public void onCompleted() {
- InstallerActivity activity = mActivity.get();
- if (activity == null) {
- // Ignore incoming connection or disconnection after activity is destroyed.
- return;
- }
-
- // MainActivity will be resume and handle rest of progress.
- activity.finishWithResult(RESULT_OK);
- }
-
- @Override
- public void onError(String displayText) {
- InstallerActivity context = mActivity.get();
- if (context == null) {
- // Ignore incoming connection or disconnection after activity is destroyed.
- return;
- }
-
- context.runOnUiThread(
- () -> {
- InstallerActivity activity = mActivity.get();
- if (activity == null) {
- // Ignore incoming connection or disconnection after activity is
- // destroyed.
- return;
- }
-
- activity.handleInstallError(displayText);
- });
- }
- }
-
- @MainThread
- public static final class InstallerServiceConnection implements ServiceConnection {
- private final WeakReference<InstallerActivity> mActivity;
-
- InstallerServiceConnection(InstallerActivity activity) {
- mActivity = new WeakReference<>(activity);
- }
-
- @Override
- public void onServiceConnected(ComponentName name, IBinder service) {
- InstallerActivity activity = mActivity.get();
- if (activity == null || activity.mInstallerServiceConnection == null) {
- // Ignore incoming connection or disconnection after activity is destroyed.
- return;
- }
- if (service == null) {
- activity.handleInternalError(new Exception("service shouldn't be null"));
- }
-
- activity.mService = IInstallerService.Stub.asInterface(service);
- activity.handleInstallerServiceConnected();
- }
-
- @Override
- public void onServiceDisconnected(ComponentName name) {
- InstallerActivity activity = mActivity.get();
- if (activity == null || activity.mInstallerServiceConnection == null) {
- // Ignore incoming connection or disconnection after activity is destroyed.
- return;
- }
-
- if (activity.mInstallerServiceConnection != null) {
- activity.unbindService(activity.mInstallerServiceConnection);
- activity.mInstallerServiceConnection = null;
- }
- activity.handleInstallerServiceDisconnected();
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
new file mode 100644
index 0000000..3eff09b
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.annotation.MainThread
+import android.content.ComponentName
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Build
+import android.os.Bundle
+import android.os.ConditionVariable
+import android.os.FileUtils
+import android.os.IBinder
+import android.os.RemoteException
+import android.text.format.Formatter
+import android.util.Log
+import android.view.KeyEvent
+import android.view.View
+import android.widget.CheckBox
+import android.widget.TextView
+import com.android.internal.annotations.VisibleForTesting
+import com.android.virtualization.terminal.ImageArchive.Companion.fromSdCard
+import com.android.virtualization.terminal.ImageArchive.Companion.getDefault
+import com.android.virtualization.terminal.InstallerActivity.InstallProgressListener
+import com.android.virtualization.terminal.InstallerActivity.InstallerServiceConnection
+import com.android.virtualization.terminal.MainActivity.TAG
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import com.google.android.material.snackbar.Snackbar
+import java.io.IOException
+import java.lang.Exception
+import java.lang.ref.WeakReference
+
+class InstallerActivity : BaseActivity() {
+ private lateinit var waitForWifiCheckbox: CheckBox
+ private lateinit var installButton: TextView
+
+ private var service: IInstallerService? = null
+ private var installerServiceConnection: ServiceConnection? = null
+ private lateinit var installProgressListener: InstallProgressListener
+ private var installRequested = false
+ private val installCompleted = ConditionVariable()
+
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setResult(RESULT_CANCELED)
+
+ installProgressListener = InstallProgressListener(this)
+
+ setContentView(R.layout.activity_installer)
+ updateSizeEstimation(ESTIMATED_IMG_SIZE_BYTES)
+ measureImageSizeAndUpdateDescription()
+
+ waitForWifiCheckbox = findViewById<CheckBox>(R.id.installer_wait_for_wifi_checkbox)
+ installButton = findViewById<TextView>(R.id.installer_install_button)
+
+ installButton.setOnClickListener(View.OnClickListener { requestInstall() })
+
+ val intent = Intent(this, InstallerService::class.java)
+ installerServiceConnection = InstallerServiceConnection(this)
+ if (!bindService(intent, installerServiceConnection!!, BIND_AUTO_CREATE)) {
+ handleInternalError(Exception("Failed to connect to installer service"))
+ }
+ }
+
+ private fun updateSizeEstimation(est: Long) {
+ val desc =
+ getString(R.string.installer_desc_text_format, Formatter.formatShortFileSize(this, est))
+ runOnUiThread {
+ val view = findViewById<TextView>(R.id.installer_desc)
+ view.text = desc
+ }
+ }
+
+ private fun measureImageSizeAndUpdateDescription() {
+ Thread {
+ val est: Long =
+ try {
+ getDefault().getSize()
+ } catch (e: IOException) {
+ Log.w(TAG, "Failed to measure image size.", e)
+ return@Thread
+ }
+ updateSizeEstimation(est)
+ }
+ .start()
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ if (Build.isDebuggable() && fromSdCard().exists()) {
+ showSnackBar("Auto installing", Snackbar.LENGTH_LONG)
+ requestInstall()
+ }
+ }
+
+ public override fun onDestroy() {
+ if (installerServiceConnection != null) {
+ unbindService(installerServiceConnection!!)
+ installerServiceConnection = null
+ }
+
+ super.onDestroy()
+ }
+
+ override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_BUTTON_START) {
+ requestInstall()
+ return true
+ }
+ return super.onKeyUp(keyCode, event)
+ }
+
+ @VisibleForTesting
+ fun waitForInstallCompleted(timeoutMillis: Long): Boolean {
+ return installCompleted.block(timeoutMillis)
+ }
+
+ private fun showSnackBar(message: String, length: Int) {
+ val snackBar = Snackbar.make(findViewById<View>(R.id.content), message, length)
+ snackBar.anchorView = waitForWifiCheckbox
+ snackBar.show()
+ }
+
+ fun handleInternalError(e: Exception) {
+ if (Build.isDebuggable()) {
+ showSnackBar(
+ e.message + ". File a bugreport to go/ferrochrome-bug",
+ Snackbar.LENGTH_INDEFINITE,
+ )
+ }
+ Log.e(TAG, "Internal error", e)
+ finishWithResult(RESULT_CANCELED)
+ }
+
+ private fun finishWithResult(resultCode: Int) {
+ if (resultCode == RESULT_OK) {
+ installCompleted.open()
+ }
+ setResult(resultCode)
+ finish()
+ }
+
+ private fun setInstallEnabled(enabled: Boolean) {
+ installButton.setEnabled(enabled)
+ waitForWifiCheckbox.setEnabled(enabled)
+ val progressBar = findViewById<LinearProgressIndicator>(R.id.installer_progress)
+ progressBar.visibility = if (enabled) View.INVISIBLE else View.VISIBLE
+
+ val resId =
+ if (enabled) R.string.installer_install_button_enabled_text
+ else R.string.installer_install_button_disabled_text
+ installButton.text = getString(resId)
+ }
+
+ @MainThread
+ private fun requestInstall() {
+ setInstallEnabled(/* enabled= */ false)
+
+ if (service != null) {
+ try {
+ service!!.requestInstall(waitForWifiCheckbox.isChecked)
+ } catch (e: RemoteException) {
+ handleInternalError(e)
+ }
+ } else {
+ Log.d(TAG, "requestInstall() is called, but not yet connected")
+ installRequested = true
+ }
+ }
+
+ @MainThread
+ fun handleInstallerServiceConnected() {
+ try {
+ service!!.setProgressListener(installProgressListener)
+ if (service!!.isInstalled()) {
+ // Finishing this activity will trigger MainActivity::onResume(),
+ // and VM will be started from there.
+ finishWithResult(RESULT_OK)
+ return
+ }
+
+ if (installRequested) {
+ requestInstall()
+ } else if (service!!.isInstalling()) {
+ setInstallEnabled(false)
+ }
+ } catch (e: RemoteException) {
+ handleInternalError(e)
+ }
+ }
+
+ @MainThread
+ fun handleInstallerServiceDisconnected() {
+ handleInternalError(Exception("InstallerService is destroyed while in use"))
+ }
+
+ @MainThread
+ private fun handleInstallError(displayText: String) {
+ showSnackBar(displayText, Snackbar.LENGTH_LONG)
+ setInstallEnabled(true)
+ }
+
+ private class InstallProgressListener(activity: InstallerActivity) :
+ IInstallProgressListener.Stub() {
+ private val activity: WeakReference<InstallerActivity> =
+ WeakReference<InstallerActivity>(activity)
+
+ override fun onCompleted() {
+ val activity = activity.get()
+ if (activity == null) {
+ // Ignore incoming connection or disconnection after activity is destroyed.
+ return
+ }
+
+ // MainActivity will be resume and handle rest of progress.
+ activity.finishWithResult(RESULT_OK)
+ }
+
+ override fun onError(displayText: String) {
+ val context = activity.get()
+ if (context == null) {
+ // Ignore incoming connection or disconnection after activity is destroyed.
+ return
+ }
+
+ context.runOnUiThread {
+ val activity = activity.get()
+ if (activity == null) {
+ // Ignore incoming connection or disconnection after activity is
+ // destroyed.
+ return@runOnUiThread
+ }
+ activity.handleInstallError(displayText)
+ }
+ }
+ }
+
+ @MainThread
+ class InstallerServiceConnection internal constructor(activity: InstallerActivity) :
+ ServiceConnection {
+ private val activity: WeakReference<InstallerActivity> =
+ WeakReference<InstallerActivity>(activity)
+
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ val activity = activity.get()
+ if (activity == null || activity.installerServiceConnection == null) {
+ // Ignore incoming connection or disconnection after activity is destroyed.
+ return
+ }
+ if (service == null) {
+ activity.handleInternalError(Exception("service shouldn't be null"))
+ }
+
+ activity.service = IInstallerService.Stub.asInterface(service)
+ activity.handleInstallerServiceConnected()
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ val activity = activity.get()
+ if (activity == null || activity.installerServiceConnection == null) {
+ // Ignore incoming connection or disconnection after activity is destroyed.
+ return
+ }
+
+ activity.unbindService(activity.installerServiceConnection!!)
+ activity.installerServiceConnection = null
+ activity.handleInstallerServiceDisconnected()
+ }
+ }
+
+ companion object {
+ private val ESTIMATED_IMG_SIZE_BYTES = FileUtils.parseSize("550MB")
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
deleted file mode 100644
index 66ab414..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
+++ /dev/null
@@ -1,355 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.app.Notification;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.Intent;
-import android.content.pm.ServiceInfo;
-import android.net.ConnectivityManager;
-import android.net.Network;
-import android.net.NetworkCapabilities;
-import android.os.Build;
-import android.os.IBinder;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.internal.annotations.GuardedBy;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.ref.WeakReference;
-import java.net.SocketException;
-import java.net.UnknownHostException;
-import java.nio.file.Path;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-public class InstallerService extends Service {
- private static final int NOTIFICATION_ID = 1313; // any unique number among notifications
-
- private final Object mLock = new Object();
-
- private Notification mNotification;
-
- @GuardedBy("mLock")
- private boolean mIsInstalling;
-
- @GuardedBy("mLock")
- private boolean mHasWifi;
-
- @GuardedBy("mLock")
- private IInstallProgressListener mListener;
-
- private ExecutorService mExecutorService;
- private ConnectivityManager mConnectivityManager;
- private MyNetworkCallback mNetworkCallback;
-
- @Override
- public void onCreate() {
- super.onCreate();
-
- Intent intent = new Intent(this, MainActivity.class);
- PendingIntent pendingIntent =
- PendingIntent.getActivity(
- this, /* requestCode= */ 0, intent, PendingIntent.FLAG_IMMUTABLE);
- mNotification =
- new Notification.Builder(this, this.getPackageName())
- .setSilent(true)
- .setSmallIcon(R.drawable.ic_launcher_foreground)
- .setContentTitle(getString(R.string.installer_notif_title_text))
- .setContentText(getString(R.string.installer_notif_desc_text))
- .setOngoing(true)
- .setContentIntent(pendingIntent)
- .build();
-
- mExecutorService =
- Executors.newSingleThreadExecutor(
- new TerminalThreadFactory(getApplicationContext()));
-
- mConnectivityManager = getSystemService(ConnectivityManager.class);
- Network defaultNetwork = mConnectivityManager.getBoundNetworkForProcess();
- if (defaultNetwork != null) {
- NetworkCapabilities capability =
- mConnectivityManager.getNetworkCapabilities(defaultNetwork);
- if (capability != null) {
- mHasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI);
- }
- }
- mNetworkCallback = new MyNetworkCallback();
- mConnectivityManager.registerDefaultNetworkCallback(mNetworkCallback);
- }
-
- @Nullable
- @Override
- public IBinder onBind(Intent intent) {
- return new InstallerServiceImpl(this);
- }
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- super.onStartCommand(intent, flags, startId);
-
- Log.d(TAG, "Starting service ...");
-
- return START_STICKY;
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
-
- Log.d(TAG, "Service is destroyed");
- if (mExecutorService != null) {
- mExecutorService.shutdown();
- }
- mConnectivityManager.unregisterNetworkCallback(mNetworkCallback);
- }
-
- private void requestInstall(boolean isWifiOnly) {
- synchronized (mLock) {
- if (mIsInstalling) {
- Log.i(TAG, "already installing..");
- return;
- } else {
- Log.i(TAG, "installing..");
- mIsInstalling = true;
- }
- }
-
- // Make service to be long running, even after unbind() when InstallerActivity is destroyed
- // The service will still be destroyed if task is remove.
- startService(new Intent(this, InstallerService.class));
- startForeground(
- NOTIFICATION_ID, mNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
-
- mExecutorService.execute(
- () -> {
- boolean success = downloadFromSdcard() || downloadFromUrl(isWifiOnly);
- stopForeground(STOP_FOREGROUND_REMOVE);
-
- synchronized (mLock) {
- mIsInstalling = false;
- }
- if (success) {
- notifyCompleted();
- }
- });
- }
-
- private boolean downloadFromSdcard() {
- ImageArchive archive = ImageArchive.fromSdCard();
-
- // Installing from sdcard is preferred, but only supported only in debuggable build.
- if (Build.isDebuggable() && archive.exists()) {
- Log.i(TAG, "trying to install /sdcard/linux/images.tar.gz");
-
- Path dest = InstalledImage.getDefault(this).getInstallDir();
- try {
- archive.installTo(dest, null);
- Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz");
- return true;
- } catch (IOException e) {
- Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz", e);
- }
- } else {
- Log.i(TAG, "Non-debuggable build doesn't support installation from /sdcard/linux");
- }
- return false;
- }
-
- private boolean checkForWifiOnly(boolean isWifiOnly) {
- if (!isWifiOnly) {
- return true;
- }
- synchronized (mLock) {
- return mHasWifi;
- }
- }
-
- // TODO(b/374015561): Support pause/resume download
- private boolean downloadFromUrl(boolean isWifiOnly) {
- if (!checkForWifiOnly(isWifiOnly)) {
- Log.e(TAG, "Install isn't started because Wifi isn't available");
- notifyError(getString(R.string.installer_error_no_wifi));
- return false;
- }
-
- Path dest = InstalledImage.getDefault(this).getInstallDir();
- try {
- ImageArchive.fromInternet()
- .installTo(
- dest,
- is -> {
- WifiCheckInputStream filter = new WifiCheckInputStream(is);
- filter.setWifiOnly(isWifiOnly);
- return filter;
- });
- } catch (WifiCheckInputStream.NoWifiException e) {
- Log.e(TAG, "Install failed because of Wi-Fi is gone");
- notifyError(getString(R.string.installer_error_no_wifi));
- return false;
- } catch (UnknownHostException | SocketException e) {
- // Log.e() doesn't print stack trace for UnknownHostException
- Log.e(TAG, "Install failed: " + e.getMessage(), e);
- notifyError(getString(R.string.installer_error_network));
- return false;
- } catch (IOException e) {
- Log.e(TAG, "Installation failed", e);
- notifyError(getString(R.string.installer_error_unknown));
- return false;
- }
- return true;
- }
-
- private void notifyError(String displayText) {
- IInstallProgressListener listener;
- synchronized (mLock) {
- listener = mListener;
- }
-
- try {
- listener.onError(displayText);
- } catch (Exception e) {
- // ignore. Activity may not exist.
- }
- }
-
- private void notifyCompleted() {
- IInstallProgressListener listener;
- synchronized (mLock) {
- listener = mListener;
- }
-
- try {
- listener.onCompleted();
- } catch (Exception e) {
- // ignore. Activity may not exist.
- }
- }
-
- private static final class InstallerServiceImpl extends IInstallerService.Stub {
- // Holds weak reference to avoid Context leak
- private final WeakReference<InstallerService> mService;
-
- public InstallerServiceImpl(InstallerService service) {
- mService = new WeakReference<>(service);
- }
-
- private InstallerService ensureServiceConnected() throws RuntimeException {
- InstallerService service = mService.get();
- if (service == null) {
- throw new RuntimeException(
- "Internal error: Installer service is being accessed after destroyed");
- }
- return service;
- }
-
- @Override
- public void requestInstall(boolean isWifiOnly) {
- InstallerService service = ensureServiceConnected();
- synchronized (service.mLock) {
- service.requestInstall(isWifiOnly);
- }
- }
-
- @Override
- public void setProgressListener(IInstallProgressListener listener) {
- InstallerService service = ensureServiceConnected();
- synchronized (service.mLock) {
- service.mListener = listener;
- }
- }
-
- @Override
- public boolean isInstalling() {
- InstallerService service = ensureServiceConnected();
- synchronized (service.mLock) {
- return service.mIsInstalling;
- }
- }
-
- @Override
- public boolean isInstalled() {
- InstallerService service = ensureServiceConnected();
- synchronized (service.mLock) {
- return !service.mIsInstalling && InstalledImage.getDefault(service).isInstalled();
- }
- }
- }
-
- private final class WifiCheckInputStream extends InputStream {
- private static final int READ_BYTES = 1024;
-
- private final InputStream mInputStream;
- private boolean mIsWifiOnly;
-
- public WifiCheckInputStream(InputStream is) {
- super();
- mInputStream = is;
- }
-
- public void setWifiOnly(boolean isWifiOnly) {
- mIsWifiOnly = isWifiOnly;
- }
-
- @Override
- public int read(byte[] buf, int offset, int numToRead) throws IOException {
- int totalRead = 0;
- while (numToRead > 0) {
- if (!checkForWifiOnly(mIsWifiOnly)) {
- throw new NoWifiException();
- }
- int read =
- mInputStream.read(buf, offset + totalRead, Math.min(READ_BYTES, numToRead));
- if (read <= 0) {
- break;
- }
- totalRead += read;
- numToRead -= read;
- }
- return totalRead;
- }
-
- @Override
- public int read() throws IOException {
- if (!checkForWifiOnly(mIsWifiOnly)) {
- throw new NoWifiException();
- }
- return mInputStream.read();
- }
-
- private static final class NoWifiException extends SocketException {
- // empty
- }
- }
-
- private final class MyNetworkCallback extends ConnectivityManager.NetworkCallback {
- @Override
- public void onCapabilitiesChanged(
- @NonNull Network network, @NonNull NetworkCapabilities capability) {
- synchronized (mLock) {
- mHasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI);
- }
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
new file mode 100644
index 0000000..9bd6a13
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
@@ -0,0 +1,333 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import com.android.internal.annotations.GuardedBy
+import com.android.virtualization.terminal.ImageArchive.Companion.fromInternet
+import com.android.virtualization.terminal.ImageArchive.Companion.fromSdCard
+import com.android.virtualization.terminal.InstalledImage.Companion.getDefault
+import com.android.virtualization.terminal.InstallerService.InstallerServiceImpl
+import com.android.virtualization.terminal.InstallerService.WifiCheckInputStream.NoWifiException
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.IOException
+import java.io.InputStream
+import java.lang.Exception
+import java.lang.RuntimeException
+import java.lang.ref.WeakReference
+import java.net.SocketException
+import java.net.UnknownHostException
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import kotlin.math.min
+
+class InstallerService : Service() {
+ private val lock = Any()
+
+ private lateinit var notification: Notification
+
+ @GuardedBy("lock") private var isInstalling = false
+
+ @GuardedBy("lock") private var hasWifi = false
+
+ @GuardedBy("lock") private var listener: IInstallProgressListener? = null
+
+ private lateinit var executorService: ExecutorService
+ private lateinit var connectivityManager: ConnectivityManager
+ private lateinit var networkCallback: MyNetworkCallback
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val intent = Intent(this, MainActivity::class.java)
+ val pendingIntent =
+ PendingIntent.getActivity(
+ this,
+ /* requestCode= */ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+ notification =
+ Notification.Builder(this, this.packageName)
+ .setSilent(true)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(getString(R.string.installer_notif_title_text))
+ .setContentText(getString(R.string.installer_notif_desc_text))
+ .setOngoing(true)
+ .setContentIntent(pendingIntent)
+ .build()
+
+ executorService =
+ Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext))
+
+ connectivityManager = getSystemService<ConnectivityManager>(ConnectivityManager::class.java)
+ val defaultNetwork = connectivityManager.boundNetworkForProcess
+ if (defaultNetwork != null) {
+ val capability = connectivityManager.getNetworkCapabilities(defaultNetwork)
+ if (capability != null) {
+ hasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
+ }
+ }
+ networkCallback = MyNetworkCallback()
+ connectivityManager.registerDefaultNetworkCallback(networkCallback)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return InstallerServiceImpl(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+
+ Log.d(TAG, "Starting service ...")
+
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ Log.d(TAG, "Service is destroyed")
+ executorService.shutdown()
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ }
+
+ private fun requestInstall(isWifiOnly: Boolean) {
+ synchronized(lock) {
+ if (isInstalling) {
+ Log.i(TAG, "already installing..")
+ return
+ } else {
+ Log.i(TAG, "installing..")
+ isInstalling = true
+ }
+ }
+
+ // Make service to be long running, even after unbind() when InstallerActivity is destroyed
+ // The service will still be destroyed if task is remove.
+ startService(Intent(this, InstallerService::class.java))
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
+ )
+
+ executorService.execute(
+ Runnable {
+ val success = downloadFromSdcard() || downloadFromUrl(isWifiOnly)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+
+ synchronized(lock) { isInstalling = false }
+ if (success) {
+ notifyCompleted()
+ }
+ }
+ )
+ }
+
+ private fun downloadFromSdcard(): Boolean {
+ val archive = fromSdCard()
+
+ // Installing from sdcard is preferred, but only supported only in debuggable build.
+ if (Build.isDebuggable() && archive.exists()) {
+ Log.i(TAG, "trying to install /sdcard/linux/images.tar.gz")
+
+ val dest = getDefault(this).installDir
+ try {
+ archive.installTo(dest, null)
+ Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz")
+ return true
+ } catch (e: IOException) {
+ Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz", e)
+ }
+ } else {
+ Log.i(TAG, "Non-debuggable build doesn't support installation from /sdcard/linux")
+ }
+ return false
+ }
+
+ private fun checkForWifiOnly(isWifiOnly: Boolean): Boolean {
+ if (!isWifiOnly) {
+ return true
+ }
+ synchronized(lock) {
+ return hasWifi
+ }
+ }
+
+ // TODO(b/374015561): Support pause/resume download
+ private fun downloadFromUrl(isWifiOnly: Boolean): Boolean {
+ if (!checkForWifiOnly(isWifiOnly)) {
+ Log.e(TAG, "Install isn't started because Wifi isn't available")
+ notifyError(getString(R.string.installer_error_no_wifi))
+ return false
+ }
+
+ val dest = getDefault(this).installDir
+ try {
+ fromInternet().installTo(dest) {
+ val filter = WifiCheckInputStream(it)
+ filter.setWifiOnly(isWifiOnly)
+ filter
+ }
+ } catch (e: NoWifiException) {
+ Log.e(TAG, "Install failed because of Wi-Fi is gone")
+ notifyError(getString(R.string.installer_error_no_wifi))
+ return false
+ } catch (e: UnknownHostException) {
+ // Log.e() doesn't print stack trace for UnknownHostException
+ Log.e(TAG, "Install failed: " + e.message, e)
+ notifyError(getString(R.string.installer_error_network))
+ return false
+ } catch (e: SocketException) {
+ Log.e(TAG, "Install failed: " + e.message, e)
+ notifyError(getString(R.string.installer_error_network))
+ return false
+ } catch (e: IOException) {
+ Log.e(TAG, "Installation failed", e)
+ notifyError(getString(R.string.installer_error_unknown))
+ return false
+ }
+ return true
+ }
+
+ private fun notifyError(displayText: String?) {
+ var listener: IInstallProgressListener
+ synchronized(lock) { listener = this@InstallerService.listener!! }
+
+ try {
+ listener.onError(displayText)
+ } catch (e: Exception) {
+ // ignore. Activity may not exist.
+ }
+ }
+
+ private fun notifyCompleted() {
+ var listener: IInstallProgressListener
+ synchronized(lock) { listener = this@InstallerService.listener!! }
+
+ try {
+ listener.onCompleted()
+ } catch (e: Exception) {
+ // ignore. Activity may not exist.
+ }
+ }
+
+ private class InstallerServiceImpl(service: InstallerService?) : IInstallerService.Stub() {
+ // Holds weak reference to avoid Context leak
+ private val mService: WeakReference<InstallerService> =
+ WeakReference<InstallerService>(service)
+
+ @Throws(RuntimeException::class)
+ fun ensureServiceConnected(): InstallerService {
+ val service: InstallerService? = mService.get()
+ if (service == null) {
+ throw RuntimeException(
+ "Internal error: Installer service is being accessed after destroyed"
+ )
+ }
+ return service
+ }
+
+ override fun requestInstall(isWifiOnly: Boolean) {
+ val service = ensureServiceConnected()
+ synchronized(service.lock) { service.requestInstall(isWifiOnly) }
+ }
+
+ override fun setProgressListener(listener: IInstallProgressListener) {
+ val service = ensureServiceConnected()
+ synchronized(service.lock) { service.listener = listener }
+ }
+
+ override fun isInstalling(): Boolean {
+ val service = ensureServiceConnected()
+ synchronized(service.lock) {
+ return service.isInstalling
+ }
+ }
+
+ override fun isInstalled(): Boolean {
+ val service = ensureServiceConnected()
+ synchronized(service.lock) {
+ return !service.isInstalling && getDefault(service).isInstalled()
+ }
+ }
+ }
+
+ private inner class WifiCheckInputStream(private val inputStream: InputStream) : InputStream() {
+ private var isWifiOnly = false
+
+ fun setWifiOnly(isWifiOnly: Boolean) {
+ this@WifiCheckInputStream.isWifiOnly = isWifiOnly
+ }
+
+ @Throws(IOException::class)
+ override fun read(buf: ByteArray?, offset: Int, numToRead: Int): Int {
+ var remaining = numToRead
+ var totalRead = 0
+ while (remaining > 0) {
+ if (!checkForWifiOnly(isWifiOnly)) {
+ throw NoWifiException()
+ }
+ val read =
+ this@WifiCheckInputStream.inputStream.read(
+ buf,
+ offset + totalRead,
+ min(READ_BYTES, remaining),
+ )
+ if (read <= 0) {
+ break
+ }
+ totalRead += read
+ remaining -= read
+ }
+ return totalRead
+ }
+
+ @Throws(IOException::class)
+ override fun read(): Int {
+ if (!checkForWifiOnly(isWifiOnly)) {
+ throw NoWifiException()
+ }
+ return this@WifiCheckInputStream.inputStream.read()
+ }
+
+ inner class NoWifiException : SocketException()
+ }
+
+ private inner class MyNetworkCallback : ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(network: Network, capability: NetworkCapabilities) {
+ synchronized(lock) {
+ hasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
+ }
+ }
+ }
+
+ companion object {
+ private const val NOTIFICATION_ID = 1313 // any unique number among notifications
+ private const val READ_BYTES = 1024
+ }
+}