1 /* 2 3 * Copyright (C) 2014 The Android Open Source Project 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.cts.verifier.sensors.base; 19 20 import com.android.cts.verifier.R; 21 import com.android.cts.verifier.TestResult; 22 import com.android.cts.verifier.sensors.helpers.SensorFeaturesDeactivator; 23 import com.android.cts.verifier.sensors.reporting.SensorTestDetails; 24 25 import junit.framework.Assert; 26 27 import android.app.Activity; 28 import android.content.Context; 29 import android.content.Intent; 30 import android.hardware.cts.helpers.ActivityResultMultiplexedLatch; 31 import android.media.MediaPlayer; 32 import android.opengl.GLSurfaceView; 33 import android.os.Bundle; 34 import android.os.SystemClock; 35 import android.os.Vibrator; 36 import android.provider.Settings; 37 import android.text.TextUtils; 38 import android.text.format.DateUtils; 39 import android.util.Log; 40 import android.view.View; 41 import android.widget.Button; 42 import android.widget.LinearLayout; 43 import android.widget.ScrollView; 44 import android.widget.TextView; 45 46 import java.util.ArrayList; 47 import java.util.concurrent.CountDownLatch; 48 import java.util.concurrent.ExecutorService; 49 import java.util.concurrent.Executors; 50 import java.util.concurrent.TimeUnit; 51 52 /** 53 * A base Activity that is used to build different methods to execute tests inside CtsVerifier. 54 * i.e. CTS tests, and semi-automated CtsVerifier tests. 55 * 56 * This class provides access to the following flow: 57 * Activity set up 58 * Execute tests (implemented by sub-classes) 59 * Activity clean up 60 * 61 * Currently the following class structure is available: 62 * - BaseSensorTestActivity : provides the platform to execute Sensor tests inside 63 * | CtsVerifier, and logging support 64 * | 65 * -- SensorCtsTestActivity : an activity that can be inherited from to wrap a CTS 66 * | sensor test, and execute it inside CtsVerifier 67 * | these tests do not require any operator interaction 68 * | 69 * -- SensorCtsVerifierTestActivity : an activity that can be inherited to write sensor 70 * tests that require operator interaction 71 */ 72 public abstract class BaseSensorTestActivity 73 extends Activity 74 implements View.OnClickListener, Runnable, ISensorTestStateContainer { 75 @Deprecated 76 protected static final String LOG_TAG = "SensorTest"; 77 78 protected final Class mTestClass; 79 80 private final int mLayoutId; 81 private final SensorFeaturesDeactivator mSensorFeaturesDeactivator; 82 83 private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); 84 private final SensorTestLogger mTestLogger = new SensorTestLogger(); 85 private final ActivityResultMultiplexedLatch mActivityResultMultiplexedLatch = 86 new ActivityResultMultiplexedLatch(); 87 private final ArrayList<CountDownLatch> mWaitForUserLatches = new ArrayList<CountDownLatch>(); 88 89 private ScrollView mLogScrollView; 90 private LinearLayout mLogLayout; 91 private Button mNextButton; 92 private Button mPassButton; 93 private Button mFailButton; 94 95 private GLSurfaceView mGLSurfaceView; 96 private boolean mUsingGlSurfaceView; 97 98 /** 99 * Constructor to be used by subclasses. 100 * 101 * @param testClass The class that contains the tests. It is dependant on test executor 102 * implemented by subclasses. 103 */ BaseSensorTestActivity(Class testClass)104 protected BaseSensorTestActivity(Class testClass) { 105 this(testClass, R.layout.sensor_test); 106 } 107 108 /** 109 * Constructor to be used by subclasses. It allows to provide a custom layout for the test UI. 110 * 111 * @param testClass The class that contains the tests. It is dependant on test executor 112 * implemented by subclasses. 113 * @param layoutId The Id of the layout to use for the test UI. The layout must contain all the 114 * elements in the base layout {@code R.layout.sensor_test}. 115 */ BaseSensorTestActivity(Class testClass, int layoutId)116 protected BaseSensorTestActivity(Class testClass, int layoutId) { 117 mTestClass = testClass; 118 mLayoutId = layoutId; 119 mSensorFeaturesDeactivator = new SensorFeaturesDeactivator(this); 120 } 121 122 @Override onCreate(Bundle savedInstanceState)123 protected void onCreate(Bundle savedInstanceState) { 124 super.onCreate(savedInstanceState); 125 setContentView(mLayoutId); 126 127 mLogScrollView = (ScrollView) findViewById(R.id.log_scroll_view); 128 mLogLayout = (LinearLayout) findViewById(R.id.log_layout); 129 mNextButton = (Button) findViewById(R.id.next_button); 130 mNextButton.setOnClickListener(this); 131 mPassButton = (Button) findViewById(R.id.pass_button); 132 mFailButton = (Button) findViewById(R.id.fail_button); 133 mGLSurfaceView = (GLSurfaceView) findViewById(R.id.gl_surface_view); 134 135 updateNextButton(false /*enabled*/); 136 mExecutorService.execute(this); 137 } 138 139 @Override onDestroy()140 protected void onDestroy() { 141 super.onDestroy(); 142 mExecutorService.shutdownNow(); 143 } 144 145 @Override onPause()146 protected void onPause() { 147 super.onPause(); 148 if (mUsingGlSurfaceView) { 149 mGLSurfaceView.onPause(); 150 } 151 } 152 153 @Override onResume()154 protected void onResume() { 155 super.onResume(); 156 if (mUsingGlSurfaceView) { 157 mGLSurfaceView.onResume(); 158 } 159 } 160 161 @Override onClick(View target)162 public void onClick(View target) { 163 synchronized (mWaitForUserLatches) { 164 for (CountDownLatch latch : mWaitForUserLatches) { 165 latch.countDown(); 166 } 167 mWaitForUserLatches.clear(); 168 } 169 } 170 171 @Override onActivityResult(int requestCode, int resultCode, Intent data)172 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 173 mActivityResultMultiplexedLatch.onActivityResult(requestCode, resultCode); 174 } 175 176 /** 177 * The main execution {@link Thread}. 178 * 179 * This function executes in a background thread, allowing the test run freely behind the 180 * scenes. It provides the following execution hooks: 181 * - Activity SetUp/CleanUp (not available in JUnit) 182 * - executeTests: to implement several execution engines 183 */ 184 @Override run()185 public void run() { 186 long startTimeNs = SystemClock.elapsedRealtimeNanos(); 187 String testName = getTestClassName(); 188 189 SensorTestDetails testDetails; 190 try { 191 mSensorFeaturesDeactivator.requestDeactivationOfFeatures(); 192 testDetails = new SensorTestDetails(testName, SensorTestDetails.ResultCode.PASS); 193 } catch (Throwable e) { 194 testDetails = new SensorTestDetails(testName, "DeactivateSensorFeatures", e); 195 } 196 197 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 198 if (resultCode == SensorTestDetails.ResultCode.SKIPPED) { 199 // this is an invalid state at this point of the test setup 200 throw new IllegalStateException("Deactivation of features cannot skip the test."); 201 } 202 if (resultCode == SensorTestDetails.ResultCode.PASS) { 203 testDetails = executeActivityTests(testName); 204 } 205 206 // we consider all remaining states at this point, because we could have been half way 207 // deactivating features 208 try { 209 mSensorFeaturesDeactivator.requestToRestoreFeatures(); 210 } catch (Throwable e) { 211 testDetails = new SensorTestDetails(testName, "RestoreSensorFeatures", e); 212 } 213 214 mTestLogger.logTestDetails(testDetails); 215 mTestLogger.logExecutionTime(startTimeNs); 216 217 // because we cannot enforce test failures in several devices, set the test UI so the 218 // operator can report the result of the test 219 promptUserToSetResult(testDetails); 220 } 221 222 /** 223 * A general set up routine. It executes only once before the first test case. 224 * 225 * NOTE: implementers must be aware of the interrupted status of the worker thread, and let 226 * {@link InterruptedException} propagate. 227 * 228 * @throws Throwable An exception that denotes the failure of set up. No tests will be executed. 229 */ activitySetUp()230 protected void activitySetUp() throws Throwable {} 231 232 /** 233 * A general clean up routine. It executes upon successful execution of {@link #activitySetUp()} 234 * and after all the test cases. 235 * 236 * NOTE: implementers must be aware of the interrupted status of the worker thread, and handle 237 * it in two cases: 238 * - let {@link InterruptedException} propagate 239 * - if it is invoked with the interrupted status, prevent from showing any UI 240 241 * @throws Throwable An exception that will be logged and ignored, for ease of implementation 242 * by subclasses. 243 */ activityCleanUp()244 protected void activityCleanUp() throws Throwable {} 245 246 /** 247 * Performs the work of executing the tests. 248 * Sub-classes implementing different execution methods implement this method. 249 * 250 * @return A {@link SensorTestDetails} object containing information about the executed tests. 251 */ executeTests()252 protected abstract SensorTestDetails executeTests() throws InterruptedException; 253 254 @Override getTestLogger()255 public SensorTestLogger getTestLogger() { 256 return mTestLogger; 257 } 258 259 @Deprecated appendText(int resId)260 protected void appendText(int resId) { 261 mTestLogger.logInstructions(resId); 262 } 263 264 @Deprecated appendText(String text)265 protected void appendText(String text) { 266 TextAppender textAppender = new TextAppender(R.layout.snsr_instruction); 267 textAppender.setText(text); 268 textAppender.append(); 269 } 270 271 @Deprecated clearText()272 protected void clearText() { 273 this.runOnUiThread(new Runnable() { 274 @Override 275 public void run() { 276 mLogLayout.removeAllViews(); 277 } 278 }); 279 } 280 281 /** 282 * Waits for the operator to acknowledge a requested action. 283 * 284 * @param waitMessageResId The action requested to the operator. 285 */ waitForUser(int waitMessageResId)286 protected void waitForUser(int waitMessageResId) throws InterruptedException { 287 CountDownLatch latch = new CountDownLatch(1); 288 synchronized (mWaitForUserLatches) { 289 mWaitForUserLatches.add(latch); 290 } 291 292 mTestLogger.logInstructions(waitMessageResId); 293 updateNextButton(true); 294 latch.await(); 295 updateNextButton(false); 296 } 297 298 /** 299 * Waits for the operator to acknowledge to begin execution. 300 */ waitForUserToBegin()301 protected void waitForUserToBegin() throws InterruptedException { 302 waitForUser(R.string.snsr_wait_to_begin); 303 } 304 305 /** 306 * {@inheritDoc} 307 */ 308 @Override waitForUserToContinue()309 public void waitForUserToContinue() throws InterruptedException { 310 waitForUser(R.string.snsr_wait_for_user); 311 } 312 313 /** 314 * {@inheritDoc} 315 */ 316 @Override executeActivity(String action)317 public int executeActivity(String action) throws InterruptedException { 318 return executeActivity(new Intent(action)); 319 } 320 321 /** 322 * {@inheritDoc} 323 */ 324 @Override executeActivity(Intent intent)325 public int executeActivity(Intent intent) throws InterruptedException { 326 ActivityResultMultiplexedLatch.Latch latch = mActivityResultMultiplexedLatch.bindThread(); 327 startActivityForResult(intent, latch.getRequestCode()); 328 return latch.await(); 329 } 330 331 /** 332 * Initializes and shows the {@link GLSurfaceView} available to tests. 333 * NOTE: initialization can be performed only once, usually inside {@link #activitySetUp()}. 334 */ initializeGlSurfaceView(final GLSurfaceView.Renderer renderer)335 protected void initializeGlSurfaceView(final GLSurfaceView.Renderer renderer) { 336 runOnUiThread(new Runnable() { 337 @Override 338 public void run() { 339 mGLSurfaceView.setVisibility(View.VISIBLE); 340 mGLSurfaceView.setRenderer(renderer); 341 mUsingGlSurfaceView = true; 342 } 343 }); 344 } 345 346 /** 347 * Closes and hides the {@link GLSurfaceView}. 348 */ closeGlSurfaceView()349 protected void closeGlSurfaceView() { 350 runOnUiThread(new Runnable() { 351 @Override 352 public void run() { 353 if (!mUsingGlSurfaceView) { 354 return; 355 } 356 mGLSurfaceView.setVisibility(View.GONE); 357 mGLSurfaceView.onPause(); 358 mUsingGlSurfaceView = false; 359 } 360 }); 361 } 362 363 /** 364 * Plays a (default) sound as a notification for the operator. 365 */ playSound()366 protected void playSound() throws InterruptedException { 367 MediaPlayer player = MediaPlayer.create(this, Settings.System.DEFAULT_NOTIFICATION_URI); 368 if (player == null) { 369 Log.e(LOG_TAG, "MediaPlayer unavailable."); 370 return; 371 } 372 player.start(); 373 try { 374 Thread.sleep(500); 375 } finally { 376 player.stop(); 377 } 378 } 379 380 /** 381 * Makes the device vibrate for the given amount of time. 382 */ vibrate(int timeInMs)383 protected void vibrate(int timeInMs) { 384 Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); 385 vibrator.vibrate(timeInMs); 386 } 387 388 /** 389 * Makes the device vibrate following the given pattern. 390 * See {@link Vibrator#vibrate(long[], int)} for more information. 391 */ vibrate(long[] pattern)392 protected void vibrate(long[] pattern) { 393 Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); 394 vibrator.vibrate(pattern, -1); 395 } 396 397 // TODO: move to sensor assertions assertTimestampSynchronization( long eventTimestamp, long receivedTimestamp, long deltaThreshold, String sensorName)398 protected String assertTimestampSynchronization( 399 long eventTimestamp, 400 long receivedTimestamp, 401 long deltaThreshold, 402 String sensorName) { 403 long timestampDelta = Math.abs(eventTimestamp - receivedTimestamp); 404 String timestampMessage = getString( 405 R.string.snsr_event_time, 406 receivedTimestamp, 407 eventTimestamp, 408 timestampDelta, 409 deltaThreshold, 410 sensorName); 411 Assert.assertTrue(timestampMessage, timestampDelta < deltaThreshold); 412 return timestampMessage; 413 } 414 getTestClassName()415 protected String getTestClassName() { 416 if (mTestClass == null) { 417 return "<unknown>"; 418 } 419 return mTestClass.getName(); 420 } 421 setLogScrollViewListener(View.OnTouchListener listener)422 protected void setLogScrollViewListener(View.OnTouchListener listener) { 423 mLogScrollView.setOnTouchListener(listener); 424 } 425 setTestResult(SensorTestDetails testDetails)426 private void setTestResult(SensorTestDetails testDetails) { 427 // the name here, must be the Activity's name because it is what CtsVerifier expects 428 String name = super.getClass().getName(); 429 String summary = mTestLogger.getOverallSummary(); 430 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 431 switch(resultCode) { 432 case SKIPPED: 433 TestResult.setPassedResult(this, name, summary); 434 break; 435 case PASS: 436 TestResult.setPassedResult(this, name, summary); 437 break; 438 case FAIL: 439 TestResult.setFailedResult(this, name, summary); 440 break; 441 case INTERRUPTED: 442 // do not set a result, just return so the test can complete 443 break; 444 default: 445 throw new IllegalStateException("Unknown ResultCode: " + resultCode); 446 } 447 } 448 executeActivityTests(String testName)449 private SensorTestDetails executeActivityTests(String testName) { 450 SensorTestDetails testDetails; 451 try { 452 activitySetUp(); 453 testDetails = new SensorTestDetails(testName, SensorTestDetails.ResultCode.PASS); 454 } catch (Throwable e) { 455 testDetails = new SensorTestDetails(testName, "ActivitySetUp", e); 456 } 457 458 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 459 if (resultCode == SensorTestDetails.ResultCode.PASS) { 460 // TODO: implement execution filters: 461 // - execute all tests and report results officially 462 // - execute single test or failed tests only 463 try { 464 testDetails = executeTests(); 465 } catch (Throwable e) { 466 // we catch and continue because we have to guarantee a proper clean-up sequence 467 testDetails = new SensorTestDetails(testName, "TestExecution", e); 468 } 469 } 470 471 // clean-up executes for all states, even on SKIPPED and INTERRUPTED there might be some 472 // intermediate state that needs to be taken care of 473 try { 474 activityCleanUp(); 475 } catch (Throwable e) { 476 testDetails = new SensorTestDetails(testName, "ActivityCleanUp", e); 477 } 478 479 return testDetails; 480 } 481 promptUserToSetResult(SensorTestDetails testDetails)482 private void promptUserToSetResult(SensorTestDetails testDetails) { 483 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 484 if (resultCode == SensorTestDetails.ResultCode.FAIL) { 485 mTestLogger.logInstructions(R.string.snsr_test_complete_with_errors); 486 enableTestResultButton( 487 mPassButton, 488 R.string.snsr_pass_on_error, 489 testDetails.cloneAndChangeResultCode(SensorTestDetails.ResultCode.PASS)); 490 enableTestResultButton( 491 mFailButton, 492 R.string.fail_button_text, 493 testDetails.cloneAndChangeResultCode(SensorTestDetails.ResultCode.FAIL)); 494 } else if (resultCode != SensorTestDetails.ResultCode.INTERRUPTED) { 495 mTestLogger.logInstructions(R.string.snsr_test_complete); 496 enableTestResultButton( 497 mPassButton, 498 R.string.pass_button_text, 499 testDetails.cloneAndChangeResultCode(SensorTestDetails.ResultCode.PASS)); 500 } 501 } 502 updateNextButton(final boolean enabled)503 private void updateNextButton(final boolean enabled) { 504 runOnUiThread(new Runnable() { 505 @Override 506 public void run() { 507 mNextButton.setEnabled(enabled); 508 } 509 }); 510 } 511 enableTestResultButton( final Button button, final int textResId, final SensorTestDetails testDetails)512 private void enableTestResultButton( 513 final Button button, 514 final int textResId, 515 final SensorTestDetails testDetails) { 516 final View.OnClickListener listener = new View.OnClickListener() { 517 @Override 518 public void onClick(View v) { 519 setTestResult(testDetails); 520 finish(); 521 } 522 }; 523 524 runOnUiThread(new Runnable() { 525 @Override 526 public void run() { 527 mNextButton.setVisibility(View.GONE); 528 button.setText(textResId); 529 button.setOnClickListener(listener); 530 button.setVisibility(View.VISIBLE); 531 } 532 }); 533 } 534 535 // a logger available until sensor reporting is in place 536 public class SensorTestLogger { 537 private static final String SUMMARY_SEPARATOR = " | "; 538 539 private final StringBuilder mOverallSummaryBuilder = new StringBuilder("\n"); 540 logTestStart(String testName)541 void logTestStart(String testName) { 542 // TODO: log the sensor information and expected execution time of each test 543 TextAppender textAppender = new TextAppender(R.layout.snsr_test_title); 544 textAppender.setText(testName); 545 textAppender.append(); 546 } 547 logInstructions(int instructionsResId, Object ... params)548 public void logInstructions(int instructionsResId, Object ... params) { 549 TextAppender textAppender = new TextAppender(R.layout.snsr_instruction); 550 textAppender.setText(getString(instructionsResId, params)); 551 textAppender.append(); 552 } 553 logMessage(int messageResId, Object ... params)554 public void logMessage(int messageResId, Object ... params) { 555 TextAppender textAppender = new TextAppender(R.layout.snsr_message); 556 textAppender.setText(getString(messageResId, params)); 557 textAppender.append(); 558 } 559 logWaitForSound()560 public void logWaitForSound() { 561 logInstructions(R.string.snsr_test_play_sound); 562 } 563 logTestDetails(SensorTestDetails testDetails)564 public void logTestDetails(SensorTestDetails testDetails) { 565 String name = testDetails.getName(); 566 String summary = testDetails.getSummary(); 567 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 568 switch (resultCode) { 569 case SKIPPED: 570 logTestSkip(name, summary); 571 break; 572 case PASS: 573 logTestPass(name, summary); 574 break; 575 case FAIL: 576 logTestFail(name, summary); 577 break; 578 case INTERRUPTED: 579 // do nothing, the test was interrupted so do we 580 break; 581 default: 582 throw new IllegalStateException("Unknown ResultCode: " + resultCode); 583 } 584 } 585 logTestPass(String testName, String testSummary)586 void logTestPass(String testName, String testSummary) { 587 testSummary = getValidTestSummary(testSummary, R.string.snsr_test_pass); 588 logTestEnd(R.layout.snsr_success, testSummary); 589 Log.d(LOG_TAG, testSummary); 590 saveResult(testName, SensorTestDetails.ResultCode.PASS, testSummary); 591 } 592 logTestFail(String testName, String testSummary)593 public void logTestFail(String testName, String testSummary) { 594 testSummary = getValidTestSummary(testSummary, R.string.snsr_test_fail); 595 logTestEnd(R.layout.snsr_error, testSummary); 596 Log.e(LOG_TAG, testSummary); 597 saveResult(testName, SensorTestDetails.ResultCode.FAIL, testSummary); 598 } 599 logTestSkip(String testName, String testSummary)600 void logTestSkip(String testName, String testSummary) { 601 testSummary = getValidTestSummary(testSummary, R.string.snsr_test_skipped); 602 logTestEnd(R.layout.snsr_warning, testSummary); 603 Log.i(LOG_TAG, testSummary); 604 saveResult(testName, SensorTestDetails.ResultCode.SKIPPED, testSummary); 605 } 606 getOverallSummary()607 String getOverallSummary() { 608 return mOverallSummaryBuilder.toString(); 609 } 610 logExecutionTime(long startTimeNs)611 void logExecutionTime(long startTimeNs) { 612 if (Thread.currentThread().isInterrupted()) { 613 return; 614 } 615 long executionTimeNs = SystemClock.elapsedRealtimeNanos() - startTimeNs; 616 long executionTimeSec = TimeUnit.NANOSECONDS.toSeconds(executionTimeNs); 617 // TODO: find a way to format times with nanosecond accuracy and longer than 24hrs 618 String formattedElapsedTime = DateUtils.formatElapsedTime(executionTimeSec); 619 logMessage(R.string.snsr_execution_time, formattedElapsedTime); 620 } 621 logTestEnd(int textViewResId, String testSummary)622 private void logTestEnd(int textViewResId, String testSummary) { 623 TextAppender textAppender = new TextAppender(textViewResId); 624 textAppender.setText(testSummary); 625 textAppender.append(); 626 } 627 getValidTestSummary(String testSummary, int defaultSummaryResId)628 private String getValidTestSummary(String testSummary, int defaultSummaryResId) { 629 if (TextUtils.isEmpty(testSummary)) { 630 return getString(defaultSummaryResId); 631 } 632 return testSummary; 633 } 634 saveResult( String testName, SensorTestDetails.ResultCode resultCode, String summary)635 private void saveResult( 636 String testName, 637 SensorTestDetails.ResultCode resultCode, 638 String summary) { 639 mOverallSummaryBuilder.append(testName); 640 mOverallSummaryBuilder.append(SUMMARY_SEPARATOR); 641 mOverallSummaryBuilder.append(resultCode.name()); 642 mOverallSummaryBuilder.append(SUMMARY_SEPARATOR); 643 mOverallSummaryBuilder.append(summary); 644 mOverallSummaryBuilder.append("\n"); 645 } 646 } 647 648 private class TextAppender { 649 private final TextView mTextView; 650 TextAppender(int textViewResId)651 public TextAppender(int textViewResId) { 652 mTextView = (TextView) getLayoutInflater().inflate(textViewResId, null /* viewGroup */); 653 } 654 setText(String text)655 public void setText(String text) { 656 mTextView.setText(text); 657 } 658 setText(int textResId)659 public void setText(int textResId) { 660 mTextView.setText(textResId); 661 } 662 append()663 public void append() { 664 runOnUiThread(new Runnable() { 665 @Override 666 public void run() { 667 mLogLayout.addView(mTextView); 668 mLogScrollView.post(new Runnable() { 669 @Override 670 public void run() { 671 mLogScrollView.fullScroll(View.FOCUS_DOWN); 672 } 673 }); 674 } 675 }); 676 } 677 } 678 } 679