/* * Copyright (C) 2015 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 org.chromium.latency.walt; import static org.chromium.latency.walt.Utils.getBooleanPreference; import android.Manifest; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbManager; import android.media.AudioManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.StrictMode; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.EditText; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.loader.content.Loader; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.util.Date; import java.util.Locale; import org.chromium.latency.walt.programmer.Programmer; public class MainActivity extends AppCompatActivity { private static final String TAG = "WALT"; private static final int PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_SHARE_LOG = 2; private static final int PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_SYSTRACE = 3; private static final int PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_WRITE_LOG = 4; private static final int PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_CLEAR_LOG = 5; private static final String LOG_FILENAME = "qstep_log.txt"; private Toolbar toolbar; LocalBroadcastManager broadcastManager; private SimpleLogger logger; private WaltDevice waltDevice; public Menu menu; public Handler handler = new Handler(); private Fragment mRobotAutomationFragment; /** * A method to display exceptions on screen. This is very useful because our USB port is taken * and we often need to debug without adb. * Based on this article: * https://trivedihardik.wordpress.com/2011/08/20/how-to-avoid-force-close-error-in-android/ */ public class LoggingExceptionHandler implements java.lang.Thread.UncaughtExceptionHandler { @Override public void uncaughtException(Thread thread, Throwable ex) { StringWriter stackTrace = new StringWriter(); ex.printStackTrace(new PrintWriter(stackTrace)); String msg = "WALT crashed with the following exception:\n" + stackTrace; // Fire a new activity showing the stack trace Intent intent = new Intent(MainActivity.this, CrashLogActivity.class); intent.putExtra("crash_log", msg); MainActivity.this.startActivity(intent); // Terminate this process android.os.Process.killProcess(android.os.Process.myPid()); System.exit(10); } } @Override protected void onResume() { super.onResume(); final UsbDevice usbDevice; Intent intent = getIntent(); if (intent != null && intent.getAction().equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { setIntent(null); // done with the intent usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); } else { usbDevice = null; } // Connect and sync clocks, but a bit later as it takes time handler.postDelayed(new Runnable() { @Override public void run() { if (usbDevice == null) { waltDevice.connect(); } else { waltDevice.connect(usbDevice); } } }, 1000); if (intent != null && AutoRunFragment.TEST_ACTION.equals(intent.getAction())) { getSupportFragmentManager().popBackStack("Automated Test", FragmentManager.POP_BACK_STACK_INCLUSIVE); Fragment autoRunFragment = new AutoRunFragment(); autoRunFragment.setArguments(intent.getExtras()); switchScreen(autoRunFragment, "Automated Test"); } // Handle robot automation originating from adb shell am if (intent != null && Intent.ACTION_SEND.equals(intent.getAction())) { Log.e(TAG, "Received Intent: " + intent.toString()); String test = intent.getStringExtra("StartTest"); if (test != null) { Log.e(TAG, "Extras \"StartTest\" = " + test); if ("TapLatencyTest".equals(test)) { mRobotAutomationFragment = new TapLatencyFragment(); switchScreen(mRobotAutomationFragment, "Tap Latency"); } else if ("ScreenResponseTest".equals(test)) { mRobotAutomationFragment = new ScreenResponseFragment(); switchScreen(mRobotAutomationFragment, "Screen Response"); } else if ("DragLatencyTest".equals(test)) { mRobotAutomationFragment = new DragLatencyFragment(); switchScreen(mRobotAutomationFragment, "Drag Latency"); } } String robotEvent = intent.getStringExtra("RobotAutomationEvent"); if (robotEvent != null && mRobotAutomationFragment != null) { Log.e(TAG, "Received robot automation event=\"" + robotEvent + "\", Fragment = " + mRobotAutomationFragment); // Writing and clearing the log is not fragment-specific, so handle them here. if (robotEvent.equals(RobotAutomationListener.WRITE_LOG_EVENT)) { attemptSaveLog(); } else if (robotEvent.equals(RobotAutomationListener.CLEAR_LOG_EVENT)) { attemptClearLog(); } else { // All other robot automation events are forwarded to the current fragment. ((RobotAutomationListener) mRobotAutomationFragment) .onRobotAutomationEvent(robotEvent); } } } } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Thread.setDefaultUncaughtExceptionHandler(new LoggingExceptionHandler()); setContentView(R.layout.activity_main); // App bar toolbar = (Toolbar) findViewById(R.id.toolbar_main); setSupportActionBar(toolbar); getSupportFragmentManager().addOnBackStackChangedListener(new FragmentManager.OnBackStackChangedListener() { @Override public void onBackStackChanged() { int stackTopIndex = getSupportFragmentManager().getBackStackEntryCount() - 1; if (stackTopIndex >= 0) { toolbar.setTitle(getSupportFragmentManager().getBackStackEntryAt(stackTopIndex).getName()); } else { toolbar.setTitle(R.string.app_name); getSupportActionBar().setDisplayHomeAsUpEnabled(false); // Disable fullscreen mode getSupportActionBar().show(); getWindow().getDecorView().setSystemUiVisibility(0); } } }); waltDevice = WaltDevice.getInstance(this); // Create front page fragment FrontPageFragment frontPageFragment = new FrontPageFragment(); FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); transaction.add(R.id.fragment_container, frontPageFragment); transaction.commit(); logger = SimpleLogger.getInstance(this); broadcastManager = LocalBroadcastManager.getInstance(this); // Add basic version and device info to the log logger.log(String.format(Locale.US, "WALT v%s (versionCode=%d)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); logger.log("WALT protocol version " + WaltDevice.PROTOCOL_VERSION); logger.log("DEVICE INFO:"); logger.log(" " + Build.FINGERPRINT); logger.log(" Build.SDK_INT=" + Build.VERSION.SDK_INT); logger.log(" os.version=" + System.getProperty("os.version")); // Set volume buttons to control media volume setVolumeControlStream(AudioManager.STREAM_MUSIC); requestSystraceWritePermission(); // Allow network operations on the main thread StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build(); StrictMode.setThreadPolicy(policy); } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.menu_main, menu); this.menu = menu; return true; } public void toast(String msg) { logger.log(msg); Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); } @Override public boolean onSupportNavigateUp() { // Go back when the back or up button on toolbar is clicked getSupportFragmentManager().popBackStack(); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. Log.i(TAG, "Toolbar button: " + item.getTitle()); switch (item.getItemId()) { case R.id.action_help: return true; case R.id.action_share: attemptSaveAndShareLog(); return true; case R.id.action_upload: showUploadLogDialog(); return true; default: return super.onOptionsItemSelected(item); } } //////////////////////////////////////////////////////////////////////////////////////////////// // Handlers for main menu clicks //////////////////////////////////////////////////////////////////////////////////////////////// private void switchScreen(Fragment newFragment, String title) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); toolbar.setTitle(title); FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); transaction.replace(R.id.fragment_container, newFragment); transaction.addToBackStack(title); transaction.commit(); } public void onClickClockSync(View view) { DiagnosticsFragment diagnosticsFragment = new DiagnosticsFragment(); switchScreen(diagnosticsFragment, "Diagnostics"); } public void onClickTapLatency(View view) { TapLatencyFragment newFragment = new TapLatencyFragment(); requestSystraceWritePermission(); switchScreen(newFragment, "Tap Latency"); } public void onClickScreenResponse(View view) { ScreenResponseFragment newFragment = new ScreenResponseFragment(); requestSystraceWritePermission(); switchScreen(newFragment, "Screen Response"); } public void onClickAudio(View view) { AudioFragment newFragment = new AudioFragment(); switchScreen(newFragment, "Audio Latency"); } public void onClickMIDI(View view) { if (MidiFragment.hasMidi(this)) { MidiFragment newFragment = new MidiFragment(); switchScreen(newFragment, "MIDI Latency"); } else { toast("This device does not support MIDI"); } } public void onClickDragLatency(View view) { DragLatencyFragment newFragment = new DragLatencyFragment(); switchScreen(newFragment, "Drag Latency"); } public void onClickAccelerometer(View view) { AccelerometerFragment newFragment = new AccelerometerFragment(); switchScreen(newFragment, "Accelerometer Latency"); } public void onClickOpenLog(View view) { LogFragment logFragment = new LogFragment(); // menu.findItem(R.id.action_help).setVisible(false); switchScreen(logFragment, "Log"); } public void onClickOpenAbout(View view) { AboutFragment aboutFragment = new AboutFragment(); switchScreen(aboutFragment, "About"); } public void onClickOpenSettings(View view) { SettingsFragment settingsFragment = new SettingsFragment(); switchScreen(settingsFragment, "Settings"); } //////////////////////////////////////////////////////////////////////////////////////////////// // Handlers for diagnostics menu clicks //////////////////////////////////////////////////////////////////////////////////////////////// public void onClickReconnect(View view) { waltDevice.connect(); } public void onClickPing(View view) { try { waltDevice.ping(); } catch (IOException e) { logger.log("Error sending ping: " + e.getMessage()); } } public void onClickStartListener(View view) { if (waltDevice.isListenerStopped()) { try { waltDevice.startListener(); } catch (IOException e) { logger.log("Error starting USB listener: " + e.getMessage()); } } else { waltDevice.stopListener(); } } public void onClickSync(View view) { try { waltDevice.syncClock(); } catch (IOException e) { logger.log("Error syncing clocks: " + e.getMessage()); } } public void onClickCheckDrift(View view) { waltDevice.checkDrift(); } public void onClickProgram(View view) { if (waltDevice.isConnected()) { // show dialog telling user to first press white button final AlertDialog dialog = new AlertDialog.Builder(this) .setTitle("Press white button") .setMessage("Please press the white button on the WALT device.") .setCancelable(false) .setNegativeButton("Cancel", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) {} }).show(); waltDevice.setConnectionStateListener(new WaltConnection.ConnectionStateListener() { @Override public void onConnect() {} @Override public void onDisconnect() { dialog.cancel(); handler.postDelayed(new Runnable() { @Override public void run() { new Programmer(MainActivity.this).program(); } }, 1000); } }); } else { new Programmer(this).program(); } } private void attemptSaveAndShareLog() { int currentPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); if (currentPermission == PackageManager.PERMISSION_GRANTED) { String filePath = saveLogToFile(); shareLogFile(filePath); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_SHARE_LOG); } } private void attemptSaveLog() { int currentPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); if (currentPermission == PackageManager.PERMISSION_GRANTED) { saveLogToFile(); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_WRITE_LOG); } } private void attemptClearLog() { int currentPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); if (currentPermission == PackageManager.PERMISSION_GRANTED) { clearLogFile(); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_CLEAR_LOG); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); final boolean isPermissionGranted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED; if (!isPermissionGranted) { logger.log("Could not get permission to write file to storage"); return; } switch (requestCode) { case PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_SHARE_LOG: attemptSaveAndShareLog(); break; case PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_WRITE_LOG: attemptSaveLog(); break; case PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_CLEAR_LOG: attemptClearLog(); break; } } public String saveLogToFile() { // Save to file to later fire an Intent.ACTION_SEND // This allows to either send the file as email attachment // or upload it to Drive. // The permissions for attachments are a mess, writing world readable files // is frowned upon, but deliberately giving permissions as part of the intent is // way too cumbersome. // A reasonable world readable location,on many phones it's /storage/emulated/Documents // TODO: make this location configurable? File path = getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); File file = null; FileOutputStream outStream = null; try { if (!path.exists()) { path.mkdirs(); } file = new File(path, LOG_FILENAME); logger.log("Saving log to: " + file + " at " + new Date()); outStream = new FileOutputStream(file); outStream.write(logger.getLogText().getBytes()); outStream.close(); logger.log("Log saved"); } catch (Exception e) { e.printStackTrace(); logger.log("Failed to write log: " + e.getMessage()); } return file.getPath(); } public void clearLogFile() { File path = getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); try { File file = new File(path, LOG_FILENAME); file.delete(); } catch (Exception e) { e.printStackTrace(); logger.log("Failed to clear log: " + e.getMessage()); } } public void shareLogFile(String filepath) { File file = new File(filepath); logger.log("Firing Intent.ACTION_SEND for file:"); logger.log(file.getPath()); Intent i = new Intent(Intent.ACTION_SEND); i.setType("text/plain"); i.putExtra(Intent.EXTRA_SUBJECT, "WALT log"); i.putExtra(Intent.EXTRA_TEXT, "Attaching log file " + file.getPath()); i.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)); try { startActivity(Intent.createChooser(i, "Send mail...")); } catch (android.content.ActivityNotFoundException ex) { toast("There are no email clients installed."); } } private static boolean startsWithHttp(String url) { return url.toLowerCase(Locale.getDefault()).startsWith("http://") || url.toLowerCase(Locale.getDefault()).startsWith("https://"); } private void showUploadLogDialog() { final AlertDialog dialog = new AlertDialog.Builder(this) .setTitle("Upload log to URL") .setView(R.layout.dialog_upload) .setPositiveButton("Upload", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) {} }) .setNegativeButton("Cancel", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) {} }) .show(); final EditText editText = (EditText) dialog.findViewById(R.id.edit_text); editText.setText(Utils.getStringPreference( MainActivity.this, R.string.preference_log_url, "")); dialog.getButton(AlertDialog.BUTTON_POSITIVE). setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { View progress = dialog.findViewById(R.id.progress_bar); String urlString = editText.getText().toString(); if (!startsWithHttp(urlString)) { urlString = "http://" + urlString; } editText.setVisibility(View.GONE); progress.setVisibility(View.VISIBLE); LogUploader uploader = new LogUploader(MainActivity.this, urlString); final String finalUrlString = urlString; uploader.registerListener(1, new Loader.OnLoadCompleteListener() { @Override public void onLoadComplete(Loader loader, Integer data) { dialog.cancel(); if (data == -1) { Toast.makeText(MainActivity.this, "Failed to upload log", Toast.LENGTH_SHORT).show(); return; } else if (data / 100 == 2) { Toast.makeText(MainActivity.this, "Log successfully uploaded", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(MainActivity.this, "Failed to upload log. Server returned status code " + data, Toast.LENGTH_SHORT).show(); } SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(MainActivity.this); preferences.edit().putString( getString(R.string.preference_log_url), finalUrlString).apply(); } }); uploader.startUpload(); } }); } private void requestSystraceWritePermission() { if (getBooleanPreference(this, R.string.preference_systrace, true)) { int currentPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); if (currentPermission != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE_SYSTRACE); } } } }