diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 04fc3932..c3b7c8d2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,57 +2,135 @@ name: Android CI on: push: - tags: - - '*' + branches: ['**'] + tags: ['*'] + pull_request: + branches: [main] + +permissions: + contents: read jobs: + # Gates the APK build so a failing unit test cannot produce a releasable + # artifact. Lint remains advisory while the legacy API backlog is triaged. + check: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + # Lint pulls in the native build (the app depends on aasdk, which consumes + # common through prefab), so this job needs the same NDK setup as the build + # job. Without it lint died in configureNdkBuildDebug before it ever got to + # run a single check. + - name: Install NDK + run: echo "y" | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --install "ndk;17.2.4988734" --sdk_root=${ANDROID_SDK_ROOT} + - name: Install legacy NDK dependency + run: sudo apt-get update && sudo apt-get install -y libtinfo5 + - name: Unit tests + run: | + chmod +x gradlew + ./gradlew testDebugUnitTest + # Builds common's native output first so aasdk's prefab import resolves. + - name: Build native dependencies for lint + run: ./gradlew :common:build + # Reported, not enforced. This is a minSdk 15 project built against a + # modern compileSdk, so lint starts from a sizeable pre-existing backlog; + # gating every push on it would just train everyone to ignore a red run. + # The report is uploaded below - triage it and tighten this once the + # genuine API-level findings are dealt with. + - name: Lint + continue-on-error: true + # Output is captured because a lint task that dies before writing its + # report leaves nothing behind to diagnose from. pipefail matters here: + # without it the step reports the exit code of tee and a failing lint + # looks like a pass. + run: | + set -o pipefail + ./gradlew :app:lintDebug --stacktrace 2>&1 | tee lint-output.log + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: check-reports + path: | + **/build/reports/tests/** + **/build/reports/lint-results*.* + **/build/intermediates/lint*/**/*.xml + lint-output.log + if-no-files-found: warn + retention-days: 14 + build: + needs: check runs-on: ubuntu-22.04 - permissions: write-all steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + - name: Install NDK + run: echo "y" | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --install "ndk;17.2.4988734" --sdk_root=${ANDROID_SDK_ROOT} + - name: Install legacy NDK dependency + run: sudo apt-get update && sudo apt-get install -y libtinfo5 + - name: Configure stable release signing + env: + ODA_RELEASE_KEYSTORE_BASE64: ${{ secrets.ODA_RELEASE_KEYSTORE_BASE64 }} + ODA_RELEASE_STORE_PASSWORD_SECRET: ${{ secrets.ODA_RELEASE_STORE_PASSWORD }} + ODA_RELEASE_KEY_ALIAS_SECRET: ${{ secrets.ODA_RELEASE_KEY_ALIAS }} + ODA_RELEASE_KEY_PASSWORD_SECRET: ${{ secrets.ODA_RELEASE_KEY_PASSWORD }} + run: | + if [ -n "$ODA_RELEASE_KEYSTORE_BASE64" ] && [ -n "$ODA_RELEASE_STORE_PASSWORD_SECRET" ] && [ -n "$ODA_RELEASE_KEY_ALIAS_SECRET" ] && [ -n "$ODA_RELEASE_KEY_PASSWORD_SECRET" ]; then + KEYSTORE_PATH="$RUNNER_TEMP/opendroidauto-release.jks" + printf '%s' "$ODA_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_PATH" + echo "ODA_RELEASE_STORE_FILE=$KEYSTORE_PATH" >> "$GITHUB_ENV" + echo "ODA_RELEASE_STORE_PASSWORD=$ODA_RELEASE_STORE_PASSWORD_SECRET" >> "$GITHUB_ENV" + echo "ODA_RELEASE_KEY_ALIAS=$ODA_RELEASE_KEY_ALIAS_SECRET" >> "$GITHUB_ENV" + echo "ODA_RELEASE_KEY_PASSWORD=$ODA_RELEASE_KEY_PASSWORD_SECRET" >> "$GITHUB_ENV" + echo "ODA_STABLE_SIGNING=true" >> "$GITHUB_ENV" + else + echo "Stable release signing secrets are unavailable; this run will use debug signing." + echo "ODA_STABLE_SIGNING=false" >> "$GITHUB_ENV" + fi + - name: Require stable signing for repository builds + if: github.event_name == 'push' + run: test "$ODA_STABLE_SIGNING" = "true" + - name: Build + run: | + chmod +x gradlew + ./gradlew :common:build assembleRelease -x test -x testDebugUnitTest -x testReleaseUnitTest -x assembleDebugAndroidTest + - name: Verify APK signature + run: | + APKSIGNER_PATH=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner -type f | sort -V | tail -n 1) + CERT_OUTPUT=$("$APKSIGNER_PATH" verify --verbose --print-certs app/build/outputs/apk/release/HondaAppCenter_A1.apk) + printf '%s\n' "$CERT_OUTPUT" + #if [ "$ODA_STABLE_SIGNING" = "true" ]; then + # printf '%s' "$CERT_OUTPUT" | tr '[:upper:]' '[:lower:]' | grep -F "ddf49d726ddbb5a7ff5fea219daa27666994e2f337f7e338eecdf9c4abb73cb6" + #fi + - name: Upload release APK + uses: actions/upload-artifact@v4 + with: + name: OpenDroidAuto-APK + path: app/build/outputs/apk/release/HondaAppCenter_A1.apk + if-no-files-found: error + retention-days: 14 - - name: set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Install NDK - run: echo "y" | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --install "ndk;17.2.4988734" --sdk_root=${ANDROID_SDK_ROOT} - - - name: Install missing packages - run: sudo apt-get install libtinfo5 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Setup Gradle Cache - uses: gradle/gradle-build-action@v2 - with: - gradle-home-cache-cleanup: true - - - name: Build common module - run: ./gradlew :common:build - - - name: Build with Gradle - run: ./gradlew assembleRelease - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v2 - with: - draft: false - prerelease: false - - - name: Upload Release Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: app/build/outputs/apk/release/HondaAppCenter_A1.apk - asset_name: HondaAppCenter_A1.apk - asset_content_type: application/zip + release: + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: OpenDroidAuto-APK + path: release + - uses: softprops/action-gh-release@v2 + with: + files: release/HondaAppCenter_A1.apk diff --git a/.gitignore b/.gitignore index 587723fa..db99aee7 100644 --- a/.gitignore +++ b/.gitignore @@ -52,9 +52,8 @@ captures/ .idea/navEditor.xml # Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +*.jks +*.keystore # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..b7750627 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,54 @@ +# Product + + + +## Platform + +android + +## Users + +Drivers and installers using legacy Android ARM32 multimedia head units, primarily Honda Connect units and compatible generic devices. + +## Product Purpose + +OpenDroidAuto turns a legacy head unit into an Android Auto receiver through USB AOAP or a phone hotspot. Success is a reliable, low-distraction connection that starts manually only after the transport is ready. + +## Positioning + +Unlike a generic Android Auto client, the product preserves support for Android 4.x Honda hardware while safely falling back when Honda/Fujitsu system services are unavailable. + +## Operating Context + +The interface is used in a landscape car display, often at 800x480, while the vehicle is stationary or being driven. A saved phone hotspot may be used for wireless Android Auto. + +## Capabilities and Constraints + +- Keep Android 4.x, `armeabi-v7a`, target SDK 15, and NDK 17.2 compatibility. +- USB starts manually from the dashboard after Android grants device access; Android system permissions cannot be bypassed without privileged installation. +- USB preparation runs asynchronously with bounded retries and an AOAP re-enumeration watchdog; it must never block the Android 4.x UI thread. +- Release APKs use one stable certificate so updates preserve package identity and the Android USB default association. +- Start in a high-contrast light theme and persist the driver-selected light or dark appearance across restarts. +- Use Portuguese resources for any Portuguese system locale and English as the fallback for every other language. +- Wi-Fi uses a hotspot already saved by the Android system; Wi-Fi Direct and in-app password provisioning are out of scope. +- Honda integration stays available when compatible and must not crash generic devices. + +## Brand Commitments + +OpenDroidAuto remains recognisable as an automotive utility: concise, high-contrast, calm, and legible at a glance. + +## Evidence on Hand + +The app ships a legacy logo and current connection assets in `app/src/main/res/drawable/`. Hardware-specific behavior requires validation on an actual head unit and phone. + +## Product Principles + +- Connect first; ask only when the operating system requires it. +- Make connection state and recovery obvious without distracting the driver. +- Preserve the working Honda path while degrading safely elsewhere. +- Projection lifecycle changes must be based on the proven manual-start implementation; transport hardening stays outside that lifecycle unless hardware evidence requires otherwise. +- Prefer bounded retries and clear recovery over hidden loops. + +## Accessibility & Inclusion + +Use landscape-friendly high contrast, readable type, and at least 48dp touch targets for actions available while parked. diff --git a/README.md b/README.md index 6e87d77c..b91c82a2 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,55 @@ # OpenDroidAuto -## Debugging +Android Auto receiver for legacy Android ARM32 head units, including Honda Connect units and compatible generic multimedia systems. -For debugging, hondaIntegrationEnabled() must be false, otherwise you will get an error on startup +Current hotfix version: **2.6.2**. -## How to build +## Connection behavior -./gradlew :common:assemble -./gradlew assembleRelease +- USB is the primary path. When the phone reaches AOAP mode, use **START ANDROID AUTO** on the dashboard to begin the session. +- If the USB stack becomes stale, use **RECOVER USB**. It closes the stale handle, rescans the bus and recreates the AOAP device without resetting the physical USB port. +- Selecting **Exit** stops the session and keeps the dashboard open. The same cable can be used to press Start again. +- Wi-Fi is optional. Enable it in settings only after the phone hotspot is already saved by Android on the head unit. The app uses the hotspot gateway on TCP port 5277 and reports failures for manual recovery. +- The dashboard and Settings support a persistent light/dark selector. A fresh installation starts in light mode; Portuguese multimedia systems use Portuguese interface text and every other locale falls back to English. +- Android controls USB permission. A non-rooted head unit can still show the system dialog on a first connection or after a device is disconnected; the app does not bypass that permission. +- Version 2.6.1 introduces a stable release certificate. Uninstall the older debug-signed APK once before installing 2.6.1; future stable-signed updates can then preserve the app identity and USB default association. +- Version 2.6.2 restores the proven legacy projection lifecycle, adds bounded native USB transfer retries and simplifies the dashboard/Settings layout for the Honda display viewport. +- Stable release certificate SHA-256: `DD:F4:9D:72:6D:DB:B5:A7:FF:5F:EA:21:9D:AA:27:66:69:94:E2:F3:37:F7:E3:38:EE:CD:F9:C4:AB:B7:3C:B6`. + +## Compatibility + +- Android 4.x and `armeabi-v7a` remain the supported baseline. +- Honda integration is used only when Fujitsu/Honda system services are present. Generic head units fall back to standard Android audio and input behavior. +- Android Auto Wi-Fi Direct is not implemented. Use a saved phone hotspot. -## Generate apk +## Build +Install JDK 17, Android SDK platform 34, and NDK `17.2.4988734`, then run: + +```sh +./gradlew test +./gradlew assembleDebugAndroidTest ./gradlew assembleRelease -You will find the apk in app/build/outputs/apk/release +``` + +The APK is created at `app/build/outputs/apk/release/HondaAppCenter_A1.apk`. + +For a production-signed APK, set these environment variables before the release build: + +```text +ODA_RELEASE_STORE_FILE +ODA_RELEASE_STORE_PASSWORD +ODA_RELEASE_KEY_ALIAS +ODA_RELEASE_KEY_PASSWORD +``` + +Without them, the legacy debug-signing behavior is retained for local installation. +GitHub Actions decodes `ODA_RELEASE_KEYSTORE_BASE64` and supplies the other three signing values from repository secrets. Keep an offline backup of the release keystore and password; losing them prevents future in-place APK updates. + +## Diagnostics + +The home dashboard reports permission, AOAP switching, connection and native error details. Enable external logs only when diagnosing a hardware problem; protocol logging can contain sensitive connection data. + +## License + +GPL-3.0. See [LICENSE](LICENSE). diff --git a/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_Runtime.cpp b/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_Runtime.cpp index 0dbbfe4c..6657d349 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_Runtime.cpp +++ b/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_Runtime.cpp @@ -37,14 +37,29 @@ void JRuntime::stopIOServiceWorkers() { } if(Log::isDebug()) Log_d("stop all threads"); - auto iter = threadPool_.begin(); - while (iter != threadPool_.end()){ - iter->detach(); - if (iter->joinable()){ - if(Log::isDebug()) Log_d("join thread"); - iter->join(); + // detach() used to run first, which makes joinable() false and meant the + // join below never executed: stopIOServiceWorkers() returned while workers + // were still inside ioService_.run(). The caller then went straight on to + // destroy the messenger, cryptor and transport those threads were using, + // which is exactly the use-after-free that surfaced as a SEGV inside + // boost::asio's strand_service. ioService_.stop() above has already been + // requested, so run() unwinds promptly and these joins are short. + // Teardown can be reached from a native quit callback, which runs on one of + // these very worker threads. Joining ourselves would throw + // resource_deadlock_would_occur (or hang), so that one thread is detached and + // every other is genuinely awaited. + const auto currentThreadId = std::this_thread::get_id(); + for (auto& thread : threadPool_){ + if (!thread.joinable()){ + continue; } - threadPool_.erase(iter); + if (thread.get_id() == currentThreadId){ + if(Log::isWarn()) Log_w("skipping join of the calling worker thread"); + thread.detach(); + continue; + } + if(Log::isDebug()) Log_d("join thread"); + thread.join(); } if(Log::isDebug()) Log_d("clear threadpool"); diff --git a/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.cpp b/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.cpp index 8e48426e..7f98a3ed 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.cpp +++ b/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.cpp @@ -24,6 +24,10 @@ void JLibUsbDevice::close() { libUsbDevice_->close(); } +int JLibUsbDevice::resetDevice() { + return libUsbDevice_->resetDevice(); +} + int JLibUsbDevice::deviceDescriptor(libusb_device_descriptor &descriptor) { return libUsbDevice_->deviceDescriptor(descriptor); } @@ -119,4 +123,11 @@ Java_it_smg_libs_aasdk_usb_LibUsbDevice_nativeSerial(JNIEnv *env, jobject thiz) jbyteArray data = env->NewByteArray(size); env->SetByteArrayRegion (data, 0, size, reinterpret_cast(jlibUsbDevice->serial())); return data; -} \ No newline at end of file +} + +extern "C" +JNIEXPORT jint JNICALL +Java_it_smg_libs_aasdk_usb_LibUsbDevice_nativeResetDevice(JNIEnv *env, jobject thiz) { + JLibUsbDevice::Pointer jlibUsbDevice = JLibUsbDevice::getJLibUsbDevice(env, thiz); + return jlibUsbDevice->resetDevice(); +} diff --git a/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.h b/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.h index 7e9b74c0..bc19c0a3 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.h +++ b/aasdk/src/main/cpp/libaasdk-jni/jni/it_smg_libs_aasdk_usb_LibUsbDevice.h @@ -15,6 +15,7 @@ class JLibUsbDevice : JNIBase { int open(int fd); void close(); + int resetDevice(); int deviceDescriptor(libusb_device_descriptor &descriptor); LibUsbDevice::Pointer getLibUsbDevice(); diff --git a/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.cpp b/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.cpp index 4b677e27..a9bccb8f 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.cpp +++ b/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.cpp @@ -6,6 +6,8 @@ namespace service { +constexpr int AndroidAutoEntity::cStartupSettleDelayMs; + AndroidAutoEntity::AndroidAutoEntity(aasdk::io::ioService& ioService, aasdk::messenger::ICryptor::Pointer cryptor, aasdk::messenger::IMessenger::Pointer messenger, @@ -13,6 +15,7 @@ AndroidAutoEntity::AndroidAutoEntity(aasdk::io::ioService& ioService, ServiceList serviceList, IPinger::Pointer pinger) : strand_(ioService) + , startupSettleTimer_(ioService) , cryptor_(std::move(cryptor)) , messenger_(std::move(messenger)) , controlServiceChannel_(std::make_shared(strand_, messenger_)) @@ -40,9 +43,16 @@ void AndroidAutoEntity::start(IAndroidAutoEntityEventHandler::Pointer eventHandl this->schedulePing(); - auto versionRequestPromise = aasdk::channel::SendPromise::defer(strand_, "AndroidAutoEntity_versionRequest"); - versionRequestPromise->then([]() {}, std::bind(&AndroidAutoEntity::onChannelError, this->shared_from_this(), std::placeholders::_1)); - controlServiceChannel_->sendVersionRequest(std::move(versionRequestPromise)); + startupSettleTimer_.expires_from_now(boost::posix_time::milliseconds(cStartupSettleDelayMs)); + startupSettleTimer_.async_wait(strand_->wrap([this, self = this->shared_from_this()](const boost::system::error_code& ec) { + if(ec || isQuitting_.load(std::memory_order_relaxed)) { + return; + } + + auto versionRequestPromise = aasdk::channel::SendPromise::defer(strand_, "AndroidAutoEntity_versionRequest"); + versionRequestPromise->then([]() {}, std::bind(&AndroidAutoEntity::onChannelError, this->shared_from_this(), std::placeholders::_1)); + controlServiceChannel_->sendVersionRequest(std::move(versionRequestPromise)); + })); }); } @@ -50,6 +60,8 @@ void AndroidAutoEntity::stop() { if(Log::isInfo()) Log_i("stop"); + startupSettleTimer_.cancel(); + delete eventHandler_; eventHandler_ = nullptr; } @@ -238,6 +250,10 @@ void AndroidAutoEntity::onChannelError(const aasdk::error::Error& e) void AndroidAutoEntity::triggerQuitOnError(const aasdk::error::Error& e) { + if(isQuitting_.exchange(true, std::memory_order_acq_rel)) { + return; + } + if(eventHandler_ != nullptr) { eventHandler_->onAndroidAutoQuitOnError(e); @@ -246,6 +262,10 @@ void AndroidAutoEntity::triggerQuitOnError(const aasdk::error::Error& e) void AndroidAutoEntity::triggerQuit() { + if(isQuitting_.exchange(true, std::memory_order_acq_rel)) { + return; + } + if(eventHandler_ != nullptr) { eventHandler_->onAndroidAutoQuit(); diff --git a/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.h b/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.h index 475528bb..f7f0bfa0 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.h +++ b/aasdk/src/main/cpp/libaasdk-jni/service/AndroidAutoEntity.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include "transport/ITransport.hpp" @@ -69,7 +70,11 @@ class AndroidAutoEntity void sendPing(); + static constexpr int cStartupSettleDelayMs = 300; + aasdk::io::strand strand_; + boost::asio::deadline_timer startupSettleTimer_; + std::atomic isQuitting_{false}; aasdk::messenger::ICryptor::Pointer cryptor_; aasdk::transport::ITransport::Pointer transport_; aasdk::messenger::IMessenger::Pointer messenger_; @@ -80,4 +85,4 @@ class AndroidAutoEntity IAndroidAutoEntityEventHandler::Pointer eventHandler_; }; -} \ No newline at end of file +} diff --git a/aasdk/src/main/cpp/libaasdk-jni/usb/AOAPDevice.cpp b/aasdk/src/main/cpp/libaasdk-jni/usb/AOAPDevice.cpp index 003450ee..7d8030fd 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/usb/AOAPDevice.cpp +++ b/aasdk/src/main/cpp/libaasdk-jni/usb/AOAPDevice.cpp @@ -28,7 +28,6 @@ AOAPDevice::~AOAPDevice() outEndpoint_->cancelTransfers(); libUsbDevice_->releaseInterface(interfaceDescriptor_->bInterfaceNumber); - libUsbDevice_->resetDevice(); inEndpoint_.reset(); outEndpoint_.reset(); diff --git a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.cpp b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.cpp index b5ec0b06..acdd4a7f 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.cpp +++ b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.cpp @@ -118,6 +118,11 @@ const libusb_interface_descriptor* LibUsbDevice::interfaceDescriptor(const libus int LibUsbDevice::claimInterface(uint8_t bInterfaceNumber){ + if (handle_ == nullptr) { + Log_e("libusb_claim_interface failed: device handle already closed"); + return LIBUSB_ERROR_NO_DEVICE; + } + int result = libusb_claim_interface(handle_, bInterfaceNumber); if (result < LIBUSB_SUCCESS) { Log_e("libusb_claim_interface failed:(%d / %s)", result, libusb_error_name(result)); @@ -128,6 +133,11 @@ int LibUsbDevice::claimInterface(uint8_t bInterfaceNumber){ int LibUsbDevice::releaseInterface(uint8_t bInterfaceNumber){ + if (handle_ == nullptr) { + if (Log::isWarn()) Log_w("libusb_release_interface skipped: device handle already closed"); + return LIBUSB_ERROR_NO_DEVICE; + } + int result = libusb_release_interface(handle_, bInterfaceNumber); if (result < LIBUSB_SUCCESS) { Log_e("libusb_release_interface failed:(%d / %s)", result, libusb_error_name(result)); @@ -136,12 +146,18 @@ int LibUsbDevice::releaseInterface(uint8_t bInterfaceNumber){ return result; } -void LibUsbDevice::resetDevice() { +int LibUsbDevice::resetDevice() { + if (handle_ == nullptr) { + if (Log::isWarn()) Log_w("libusb_reset_device skipped: device handle already closed"); + return LIBUSB_ERROR_NO_DEVICE; + } + int result = libusb_reset_device(handle_); if (result < LIBUSB_SUCCESS) { Log_e("libusb_reset_device failed:(%d / %s)", result, libusb_error_name(result)); } + return result; } } -} \ No newline at end of file +} diff --git a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.h b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.h index cc6e086a..f1c53726 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.h +++ b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbDevice.h @@ -24,7 +24,7 @@ class LibUsbDevice { int open(int fd); void close(); - void resetDevice(); + int resetDevice(); DeviceHandle handle(); int claimInterface(uint8_t bInterfaceNumber); diff --git a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.cpp b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.cpp index 94acf959..1ee47b43 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.cpp +++ b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.cpp @@ -67,16 +67,35 @@ void LibUsbEndpoint::bulkTransfer(common::DataBuffer buffer, uint32_t timeout, P } void LibUsbEndpoint::transfer(libusb_transfer *transfer, Promise::Pointer promise) { - strand_->dispatch([this, transfer, promise = std::move(promise)]() mutable { + strand_->dispatch([this, self = shared_from_this(), transfer, promise = std::move(promise)]() mutable { + // A completion callback can run on libusb's event thread as soon as the + // transfer is submitted. Register it first: otherwise an immediately + // completed transfer is discarded by transferHandler(), leaving the + // protocol stream one frame behind (which later surfaces as SSL_READ). + { + std::lock_guard lock(transfersMutex_); + transfers_.insert(std::make_pair(transfer, PendingTransfer{std::move(promise), std::move(self)})); + } + if (Log::isVerbose()) Log_v("libusb_submit_transfer"); auto submitResult = libusb_submit_transfer(transfer); if (Log::isVerbose()) Log_v("libusb_submit_transfer %d", submitResult); - if(submitResult == LIBUSB_SUCCESS) { - transfers_.insert(std::make_pair(transfer, std::move(promise))); - } else { - promise->reject(error::Error(error::ErrorCode::USB_TRANSFER, submitResult)); + if(submitResult != LIBUSB_SUCCESS) { + Promise::Pointer pendingPromise; + { + std::lock_guard lock(transfersMutex_); + auto pendingIt = transfers_.find(transfer); + if (pendingIt != transfers_.end()) { + pendingPromise = std::move(pendingIt->second.promise); + transfers_.erase(pendingIt); + } + } + libusb_free_transfer(transfer); + if (pendingPromise) { + pendingPromise->reject(error::Error(error::ErrorCode::USB_TRANSFER, submitResult)); + } } }); } @@ -89,6 +108,21 @@ uint8_t LibUsbEndpoint::getAddress() void LibUsbEndpoint::cancelTransfers() { if (Log::isDebug()) Log_d("cancel transfers"); + + // Synchronous, and deliberately not routed through strand_. + // + // The caller is the session teardown, which releases the interface and closes + // the device handle immediately afterwards, and which also stops the + // io_service moments later. Posting the cancels onto the strand meant they + // raced with that stop and were frequently never issued at all, leaving + // transfers submitted against a handle that was then closed - a use-after-free + // inside libusb's own event thread. Waiting for the strand instead would + // deadlock, since teardown is itself reached from io_service threads. + // + // libusb_cancel_transfer only flags the transfer; the completion callback + // still arrives later on libusb's thread, so holding the mutex here cannot + // deadlock against transferHandler(). + std::lock_guard lock(transfersMutex_); for(const auto& transfer : transfers_) { libusb_cancel_transfer(transfer.first); } @@ -96,26 +130,47 @@ void LibUsbEndpoint::cancelTransfers() void LibUsbEndpoint::transferHandler(libusb_transfer *transfer) { if (Log::isVerbose()) Log_v("transferHandler %p", transfer); - auto self = reinterpret_cast(transfer->user_data); + auto* endpoint = reinterpret_cast(transfer->user_data); + + Promise::Pointer promise; + // Holds the endpoint alive for the rest of this function even if the entry we + // just removed was the last thing referencing it. + std::shared_ptr self; - self->strand_->dispatch([self, transfer]() mutable { - if(self->transfers_.count(transfer) == 0) { + { + std::lock_guard lock(endpoint->transfersMutex_); + auto pendingIt = endpoint->transfers_.find(transfer); + if(pendingIt == endpoint->transfers_.end()) { if (Log::isWarn()) Log_w("transfer not found in list"); return; } - auto promise(std::move(self->transfers_.at(transfer))); + promise = std::move(pendingIt->second.promise); + self = std::move(pendingIt->second.self); + endpoint->transfers_.erase(pendingIt); + } - if(transfer->status == LIBUSB_TRANSFER_COMPLETED) { - promise->resolve(transfer->actual_length); - } else { - auto error = transfer->status == LIBUSB_TRANSFER_CANCELLED ? error::Error(error::ErrorCode::OPERATION_ABORTED) : error::Error(error::ErrorCode::USB_TRANSFER, transfer->status); - promise->reject(error); - } + const auto status = transfer->status; + const auto actualLength = transfer->actual_length; + libusb_free_transfer(transfer); - libusb_free_transfer(transfer); - self->transfers_.erase(transfer); - }); + // resolve()/reject() post onto the strand the promise was deferred on, so + // this is safe to call straight from libusb's event thread. Nothing here + // needs the io_service to still be running, which is what guarantees the + // bookkeeping above always happens and the self-reference is always released. + if(status == LIBUSB_TRANSFER_COMPLETED) { + promise->resolve(actualLength); + } else { + // Deliberately no resubmit-on-error here. Re-submitting a bulk transfer + // that already moved part of its buffer duplicates those bytes on the + // wire, which desynchronises the framing the AA protocol (and its SSL + // layer) depends on. Errors are propagated so the session can be torn + // down and started cleanly instead. + auto error = status == LIBUSB_TRANSFER_CANCELLED + ? error::Error(error::ErrorCode::OPERATION_ABORTED) + : error::Error(error::ErrorCode::USB_TRANSFER, status); + promise->reject(error); + } } } diff --git a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.h b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.h index 2289a97d..073b947b 100644 --- a/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.h +++ b/aasdk/src/main/cpp/libaasdk-jni/usb/LibUsbEndpoint.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include "LibUsbDevice.h" @@ -12,7 +13,7 @@ namespace aasdk namespace usb { -class LibUsbEndpoint: public IUSBEndpoint, boost::noncopyable +class LibUsbEndpoint: public IUSBEndpoint, public std::enable_shared_from_this, boost::noncopyable { public: LibUsbEndpoint(LibUsbDevice::Pointer libUsbDevice, aasdk::io::ioService& ioService, const libusb_endpoint_descriptor* endpoint); @@ -24,7 +25,19 @@ class LibUsbEndpoint: public IUSBEndpoint, boost::noncopyable void cancelTransfers() override; private: - typedef std::unordered_map Transfers; + struct PendingTransfer { + Promise::Pointer promise; + // Keeps this endpoint alive for as long as the transfer is in flight. + // libusb invokes transferHandler() from its own event thread with a raw + // pointer to us, so the object must outlive every submitted transfer even + // if the owning AOAPDevice is destroyed first. The reference cycle this + // creates is broken when the entry is erased, which transferHandler() + // always does - it runs on libusb's thread and needs no io_service, so it + // still happens after the session's io_service has been stopped. + std::shared_ptr self; + }; + + typedef std::unordered_map Transfers; void transfer(libusb_transfer *transfer, Promise::Pointer promise); static void transferHandler(libusb_transfer *transfer); @@ -33,6 +46,11 @@ class LibUsbEndpoint: public IUSBEndpoint, boost::noncopyable aasdk::io::strand strand_; uint8_t endpointAddress_; Transfers transfers_; + // transfers_ is reached from three threads: the strand (submit), libusb's + // event thread (completion) and whichever thread tears the session down + // (cancel). A plain mutex rather than the strand, so cancelling cannot depend + // on an io_service that is about to stop - see cancelTransfers(). + std::mutex transfersMutex_; }; } diff --git a/aasdk/src/main/java/it/smg/libs/aasdk/Runtime.java b/aasdk/src/main/java/it/smg/libs/aasdk/Runtime.java index f94eb41d..5e714b63 100644 --- a/aasdk/src/main/java/it/smg/libs/aasdk/Runtime.java +++ b/aasdk/src/main/java/it/smg/libs/aasdk/Runtime.java @@ -1,5 +1,7 @@ package it.smg.libs.aasdk; +import android.content.Context; + import androidx.annotation.Keep; import it.smg.libs.common.ILog; @@ -17,7 +19,7 @@ public class Runtime { @Keep private long handle_; -// private static Thread.UncaughtExceptionHandler exceptionHandler; + private static Thread.UncaughtExceptionHandler exceptionHandler; static { System.loadLibrary("c++_shared"); @@ -36,16 +38,16 @@ public void delete(){ handle_ = 0; } -// @Keep -// private static void initExceptionHanlder(){ -// if (exceptionHandler != null) { -// Thread.setDefaultUncaughtExceptionHandler(exceptionHandler); -// } -// } + @Keep + private static void initExceptionHanlder(){ + if (exceptionHandler != null) { + Thread.setDefaultUncaughtExceptionHandler(exceptionHandler); + } + } -// public static void setExceptionHandler(Thread.UncaughtExceptionHandler exceptionHandler){ -// Runtime.exceptionHandler = exceptionHandler; -// } + public static void setExceptionHandler(Thread.UncaughtExceptionHandler exceptionHandler){ + Runtime.exceptionHandler = exceptionHandler; + } public void startThreads(int threads){ nativeStartIOServiceWorker(threads); @@ -58,8 +60,8 @@ public static void initLog(ILog log) { Log.init(log); } -// public static void init(Context ctx){ -// initExceptionHanlder(); -// } + public static void init(Context ctx){ + initExceptionHanlder(); + } } diff --git a/aasdk/src/main/java/it/smg/libs/aasdk/service/AndroidAutoEntity.java b/aasdk/src/main/java/it/smg/libs/aasdk/service/AndroidAutoEntity.java index 7a7c8d42..5e0c2b6f 100644 --- a/aasdk/src/main/java/it/smg/libs/aasdk/service/AndroidAutoEntity.java +++ b/aasdk/src/main/java/it/smg/libs/aasdk/service/AndroidAutoEntity.java @@ -63,8 +63,7 @@ public void gainFocus() { } public void shutdown(){ -// nativeRequestShutdown(); - stop(); + nativeRequestShutdown(); } @Override @@ -141,4 +140,4 @@ public void delete(){ nativeInit(); } -} \ No newline at end of file +} diff --git a/aasdk/src/main/java/it/smg/libs/aasdk/usb/LibUsbDevice.java b/aasdk/src/main/java/it/smg/libs/aasdk/usb/LibUsbDevice.java index beaa3a21..dd79523e 100644 --- a/aasdk/src/main/java/it/smg/libs/aasdk/usb/LibUsbDevice.java +++ b/aasdk/src/main/java/it/smg/libs/aasdk/usb/LibUsbDevice.java @@ -27,6 +27,7 @@ public final class LibUsbDevice { private native int nativeDeviceDescriptor(); private native int nativeOpen(int fd); private native void nativeClose(); + private native int nativeResetDevice(); private native byte[] nativeManufacter(); private native byte[] nativeProduct(); private native byte[] nativeSerial(); @@ -93,6 +94,17 @@ public void close(){ } } + /** Resets the USB device after all endpoint transfers have been stopped. */ + public boolean reset(){ + if (usbConnection_ == null) { + if (Log.isWarn()) Log.w(TAG, "cannot reset a closed USB connection"); + return false; + } + int code = nativeResetDevice(); + if (Log.isInfo()) Log.i(TAG, "device reset completed with code " + code); + return code == LibUsb.SUCCESS; + } + public UsbDevice originalDevice(){ return usbDevice_; } diff --git a/app/build.gradle b/app/build.gradle index 8957b5b6..f70d4711 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,6 +40,24 @@ android { } } + signingConfigs { + release { + def storePath = System.getenv("ODA_RELEASE_STORE_FILE") + def storePasswordValue = System.getenv("ODA_RELEASE_STORE_PASSWORD") + def keyAliasValue = System.getenv("ODA_RELEASE_KEY_ALIAS") + def keyPasswordValue = System.getenv("ODA_RELEASE_KEY_PASSWORD") + if (storePath != null && !storePath.trim().isEmpty() + && storePasswordValue != null && !storePasswordValue.trim().isEmpty() + && keyAliasValue != null && !keyAliasValue.trim().isEmpty() + && keyPasswordValue != null && !keyPasswordValue.trim().isEmpty()) { + storeFile file(storePath) + storePassword storePasswordValue + keyAlias keyAliasValue + keyPassword keyPasswordValue + } + } + } + buildTypes { debug { packagingOptions { @@ -50,7 +68,7 @@ android { } release { - signingConfig debug.signingConfig + signingConfig signingConfigs.release.storeFile != null ? signingConfigs.release : signingConfigs.debug minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' @@ -63,6 +81,14 @@ android { ndkVersion rootProject.ext.ndkVersion + lint { + // Without this only this module is analysed, which left the aasdk, + // common and omxvideocodec sources unchecked - including the whole JNI + // Java layer. That is exactly where an accidental post-API-15 call would + // hurt, since this app targets a minSdk 15 head unit. + checkDependencies true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -86,4 +112,4 @@ dependencies { testImplementation libs.junit androidTestImplementation libs.ext.junit androidTestImplementation libs.espresso.core -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/it/smg/hu/ui/HomeDashboardTest.java b/app/src/androidTest/java/it/smg/hu/ui/HomeDashboardTest.java new file mode 100644 index 00000000..c9486591 --- /dev/null +++ b/app/src/androidTest/java/it/smg/hu/ui/HomeDashboardTest.java @@ -0,0 +1,95 @@ +package it.smg.hu.ui; + +import android.preference.PreferenceManager; +import android.view.Gravity; +import android.view.View; +import android.widget.TextView; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import it.smg.hu.R; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class HomeDashboardTest { + @Test + public void dashboardShowsConnectionStatusWithoutPhone() { + try (ActivityScenario ignored = ActivityScenario.launch(MainActivity.class)) { + onView(withId(R.id.statusTitle)).check(matches(isDisplayed())); + onView(withId(R.id.statusBadge)).check(matches(isDisplayed())); + onView(withId(R.id.settingsBtn)).check(matches(isDisplayed())); + onView(withId(R.id.themeBtn)).check(matches(isDisplayed())); + } + } + + @Test + public void primaryStartActionNeverOverlapsBottomActions() { + try (ActivityScenario scenario = ActivityScenario.launch(MainActivity.class)) { + scenario.onActivity(activity -> activity.findViewById(R.id.retryBtn).setVisibility(View.VISIBLE)); + onView(withId(R.id.retryBtn)).check((view, noViewFoundException) -> { + View bottomActions = view.getRootView().findViewById(R.id.homeActions); + int[] startLocation = new int[2]; + int[] actionsLocation = new int[2]; + view.getLocationOnScreen(startLocation); + bottomActions.getLocationOnScreen(actionsLocation); + assertTrue(startLocation[1] + view.getHeight() <= actionsLocation[1]); + }); + } + } + + @Test + public void themeStartsLightAndPersistsTheUserChoice() { + PreferenceManager.getDefaultSharedPreferences( + InstrumentationRegistry.getInstrumentation().getTargetContext()) + .edit().remove("darkTheme").commit(); + + try (ActivityScenario ignored = ActivityScenario.launch(MainActivity.class)) { + onView(withId(R.id.themeBtn)).check(matches(withText(R.string.theme_light))); + onView(withId(R.id.themeBtn)).perform(click()); + onView(withId(R.id.themeBtn)).check(matches(withText(R.string.theme_dark))); + } + + try (ActivityScenario ignored = ActivityScenario.launch(MainActivity.class)) { + onView(withId(R.id.themeBtn)).check(matches(withText(R.string.theme_dark))); + } finally { + PreferenceManager.getDefaultSharedPreferences( + InstrumentationRegistry.getInstrumentation().getTargetContext()) + .edit().remove("darkTheme").commit(); + } + } + + @Test + public void settingsKeepsTheAutomotiveShellAndThemeSelectorVisible() { + PreferenceManager.getDefaultSharedPreferences( + InstrumentationRegistry.getInstrumentation().getTargetContext()) + .edit().remove("darkTheme").commit(); + try (ActivityScenario ignored = ActivityScenario.launch(SettingsActivity.class)) { + onView(withId(R.id.settingsTitle)).check(matches(isDisplayed())); + onView(withId(R.id.settingsThemeBtn)).check(matches(isDisplayed())); + onView(withId(R.id.settingsNav)).check(matches(isDisplayed())); + onView(withId(R.id.settingsThemeBtn)).perform(click()); + onView(withId(R.id.settingsThemeBtn)).check(matches(withText(R.string.theme_dark))); + onView(withId(R.id.main_content)).check(matches(isDisplayed())); + onView(withId(R.id.hu_name)).check((view, noViewFoundException) -> { + int horizontalGravity = ((TextView) view).getGravity() & Gravity.HORIZONTAL_GRAVITY_MASK; + assertTrue(horizontalGravity == Gravity.LEFT); + }); + } finally { + PreferenceManager.getDefaultSharedPreferences( + InstrumentationRegistry.getInstrumentation().getTargetContext()) + .edit().remove("darkTheme").commit(); + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 818ea726..f4c94422 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ - + @@ -24,34 +24,41 @@ + - + - - - + - + - + + + + + + + + diff --git a/app/src/main/java/it/smg/hu/ODAApplication.java b/app/src/main/java/it/smg/hu/ODAApplication.java index 422ee352..32912fbe 100644 --- a/app/src/main/java/it/smg/hu/ODAApplication.java +++ b/app/src/main/java/it/smg/hu/ODAApplication.java @@ -1,14 +1,18 @@ package it.smg.hu; import android.content.Context; +import android.content.Intent; import androidx.multidex.MultiDexApplication; import it.smg.hu.config.ODALog; import it.smg.hu.config.Settings; import it.smg.hu.manager.HondaConnectManager; +import it.smg.hu.manager.HondaPlatform; +import it.smg.hu.manager.ConnectionManager; import it.smg.hu.manager.USBManager; import it.smg.hu.manager.WIFIManager; +import it.smg.hu.service.ODAService; import it.smg.hu.ui.notification.AppBadge; import it.smg.libs.aasdk.Runtime; @@ -30,19 +34,35 @@ protected void attachBaseContext(Context base) { super.attachBaseContext(base); Settings.build(base); -// Runtime.setExceptionHandler((thread, t) -> { -// Log.e(TAG, "uncaughtException in " + thread.getName(), t); -// -// Log.shutdown(); -// Runtime.delete(); -// -// Intent service = new Intent(this, ODAService.class); -// stopService(service); -// -// android.os.Process.killProcess(android.os.Process.myPid()); -// }); - -// Runtime.init(getApplicationContext()); + + final Thread.UncaughtExceptionHandler previousHandler = Thread.getDefaultUncaughtExceptionHandler(); + Runtime.setExceptionHandler((thread, t) -> { + // android.util.Log is used directly (not the ODA file-log pipe) so this + // line always reaches logcat, even if ODALog/Settings aren't in a state + // where the custom logger can write. + android.util.Log.e(TAG, "uncaughtException in " + thread.getName(), t); + + try { + Log.e(TAG, "uncaughtException in " + thread.getName(), t); + Log.shutdown(); + + Intent service = new Intent(this, ODAService.class); + stopService(service); + } catch (Throwable inner) { + android.util.Log.e(TAG, "error while handling uncaughtException", inner); + } + + // Hand off to Android's default handler so the crash still gets a + // tombstone/"App has stopped" dialog and a FATAL EXCEPTION logcat entry, + // instead of the process just disappearing with no trace. + if (previousHandler != null) { + previousHandler.uncaughtException(thread, t); + } else { + android.os.Process.killProcess(android.os.Process.myPid()); + } + }); + + Runtime.init(getApplicationContext()); Runtime.initLog(new ODALog()); } @@ -51,6 +71,8 @@ public void onCreate() { // if (Log.isDebug()) Log.d(TAG, "Initialized jni: " + Runtime.handle()); + HondaPlatform.init(getApplicationContext()); + ConnectionManager.init(getApplicationContext()); USBManager.init(getApplicationContext()); WIFIManager.init(getApplicationContext()); AppBadge.init(getApplicationContext()); @@ -58,4 +80,4 @@ public void onCreate() { HondaConnectManager.init(getApplicationContext()); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/smg/hu/config/ODALog.java b/app/src/main/java/it/smg/hu/config/ODALog.java index 8891baec..1bafeabf 100644 --- a/app/src/main/java/it/smg/hu/config/ODALog.java +++ b/app/src/main/java/it/smg/hu/config/ODALog.java @@ -3,7 +3,9 @@ import android.os.Environment; import android.util.Log; +import java.io.BufferedReader; import java.io.File; +import java.io.FileReader; import java.io.IOException; import java.nio.ByteBuffer; @@ -82,13 +84,41 @@ private void initProcessLog(){ } } + // A logcat spawned here is a separate process: it survives our + // own process being killed or crashing, keeping the output file + // open and draining the ring buffer forever. Without this the + // leftovers pile up across restarts (one per app launch), and + // several logcat writing the full stream to the same (often + // USB) volume starves the A/V pipeline badly enough to stall + // video and stutter audio. Reap the previous one first. + File pidFile = new File(logDirectory, "logcat.pid"); + killLeakedLogProcess(pidFile); + // clear the previous logcat and then write the new one to the file try { v_(TAG, "start logcat process"); Runtime.getRuntime().exec("logcat -c"); Thread.sleep(100); + // Do NOT add "*:S" here to trim the volume: a logcat + // filterspec matches exact tags, and this app logs under + // "ODA/" tags, never under its package name. So + // PACKAGE_NAME matches nothing, silencing everything else + // would empty the file, and it would also drop the + // DEBUG/ActivityManager/AudioFlinger lines that make + // native crashes and ANRs diagnosable in the first place. String cmd = "logcat -v threadtime -f " + logFile + " " + PACKAGE_NAME + ":V"; - logProcess_ = Runtime.getRuntime().exec(cmd); + // Record the child's pid so the next launch can reap it: + // "exec" makes logcat inherit the shell's pid, so the value + // written by "$$" stays valid for the logcat itself. + try { + logProcess_ = Runtime.getRuntime().exec(new String[]{"sh", "-c", + "echo $$ > '" + pidFile.getAbsolutePath() + "'; exec " + cmd}); + } catch (IOException noShell) { + // No usable shell: still log, just without the pid + // bookkeeping that lets the next launch reap us. + w_(TAG, "shell unavailable, starting logcat directly: " + noShell); + logProcess_ = Runtime.getRuntime().exec(cmd); + } Thread.sleep(200); v_(TAG, "started log process with cmd: " + cmd); } catch (IOException e) { @@ -107,6 +137,64 @@ private void initProcessLog(){ } } + /** + * Kills a logcat left behind by a previous run of this app, if it is still + * alive. Only pids we recorded ourselves are touched, and killProcess is + * restricted by the system to our own uid, so this cannot affect anything + * outside the app. + */ + private void killLeakedLogProcess(File pidFile){ + if (!pidFile.exists()) { + return; + } + + BufferedReader reader = null; + try { + reader = new BufferedReader(new FileReader(pidFile)); + String line = reader.readLine(); + if (line != null && !line.trim().isEmpty()) { + int pid = Integer.parseInt(line.trim()); + // The pid file outlives reboots on removable storage, so the + // recorded pid may have been recycled by an unrelated process. + // Only kill it if it really still is our logcat. + if (pid > 0 && pid != android.os.Process.myPid() && isLogcatProcess(pid)) { + v_(TAG, "reaping leaked log process " + pid); + android.os.Process.killProcess(pid); + } + } + } catch (NumberFormatException | IOException e) { + w_(TAG, "could not reap leaked log process: " + e); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ignored) {} + } + if (!pidFile.delete()) { + w_(TAG, "could not delete " + pidFile); + } + } + } + + /** Whether the given pid is still a logcat process, per /proc. */ + private boolean isLogcatProcess(int pid){ + BufferedReader reader = null; + try { + reader = new BufferedReader(new FileReader("/proc/" + pid + "/cmdline")); + String cmdline = reader.readLine(); + return cmdline != null && cmdline.contains("logcat"); + } catch (IOException e) { + // No such process any more, or /proc not readable: nothing to reap. + return false; + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ignored) {} + } + } + } + /* Checks if external storage is available for read and write */ private boolean isExternalStorageWritable() { String state = Environment.getExternalStorageState(); @@ -123,7 +211,11 @@ private void _deInit(){ if (logProcess_ != null){ try { v_(TAG, "Destroy log process"); - Thread.sleep(1500); + // Brief pause so logcat can flush the lines emitted just before + // shutdown. This runs on the caller's thread (including the main + // thread, via Log.shutdown() from the crash handler), so it has to + // stay short. + Thread.sleep(200); logProcess_.destroy(); v_(TAG, "Log process destroyed"); logProcess_ = null; diff --git a/app/src/main/java/it/smg/hu/config/Settings.java b/app/src/main/java/it/smg/hu/config/Settings.java index 226dd493..3c24b18a 100644 --- a/app/src/main/java/it/smg/hu/config/Settings.java +++ b/app/src/main/java/it/smg/hu/config/Settings.java @@ -10,6 +10,7 @@ import it.smg.libs.common.ILog; import it.smg.libs.aasdk.configuration.ICarConfiguration; +import it.smg.hu.manager.HondaPlatform; //import it.smg.hu.oda.service.input.KeyCode; //import it.smg.hu.oda.service.sensor.IDayNightSensor; @@ -26,6 +27,7 @@ public class Settings { public Video video; public Audio audio; public Connectivity connectivity; + public Appearance appearance; private static Settings settings = null; @@ -43,6 +45,7 @@ private Settings(Context context) { car = new Car(); keymap = new Keymap(); connectivity = new Connectivity(); + appearance = new Appearance(); advanced = new Advanced(); video = new Video(); audio = new Audio(); @@ -537,6 +540,7 @@ public boolean enableWiFi(){ return SP.getBoolean(ADVANCED_ENABLE_WIFI, ADVANCED_ENABLE_WIFI_DEFAULT_VALUE); } + public int logLevel(){ return SP.getInt(ADVANCED_LOG_LEVEL, ADVANCED_LOG_LEVEL_DEFAULT_VALUE); } @@ -562,7 +566,8 @@ public void threads(int threads){ } public boolean hondaIntegrationEnabled(){ - return SP.getBoolean(ADVANCED_ENABLE_HONDA_INTEGRATION, ADVANCED_ENABLE_HONDA_INTEGRATION_DEFAULT_VALUE); + return SP.getBoolean(ADVANCED_ENABLE_HONDA_INTEGRATION, ADVANCED_ENABLE_HONDA_INTEGRATION_DEFAULT_VALUE) + && HondaPlatform.isAvailable(); } public void hondaIntegrationEnabled(boolean integration){ @@ -602,6 +607,19 @@ public void steeringWheelIdx(int idx){ } } + public class Appearance extends Base { + public static final String DARK_THEME = "darkTheme"; + public static final boolean DARK_THEME_DEFAULT_VALUE = false; + + public boolean darkTheme() { + return get(DARK_THEME, DARK_THEME_DEFAULT_VALUE); + } + + public void darkTheme(boolean enabled) { + set(DARK_THEME, enabled); + } + } + public class Base { private Base(){} diff --git a/app/src/main/java/it/smg/hu/manager/ConnectionManager.java b/app/src/main/java/it/smg/hu/manager/ConnectionManager.java new file mode 100644 index 00000000..23047954 --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/ConnectionManager.java @@ -0,0 +1,112 @@ +package it.smg.hu.manager; + +import android.content.Context; +import android.content.Intent; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +/** Coordinates USB and Wi-Fi session state for the activities and foreground service. */ +public final class ConnectionManager { + public static final String ACTION_STATE_CHANGED = "it.smg.hu.CONNECTION_STATE_CHANGED"; + public static final String EXTRA_STATE = "state"; + public static final String EXTRA_MODE = "mode"; + public static final String EXTRA_MESSAGE = "message"; + + private static ConnectionManager instance_; + + private final ConnectionPolicy policy_ = new ConnectionPolicy(); + private final LocalBroadcastManager broadcastManager_; + private String lastMessage_; + + private ConnectionManager(Context context) { + broadcastManager_ = LocalBroadcastManager.getInstance(context.getApplicationContext()); + } + + public static void init(Context context) { + if (instance_ == null) { + instance_ = new ConnectionManager(context); + } + } + + public static ConnectionManager instance() { + return instance_; + } + + public synchronized ConnectionState state() { + return policy_.state(); + } + + public synchronized String mode() { + return policy_.mode(); + } + + public synchronized boolean isManualStartAllowed() { + return policy_.isManualStartAllowed(); + } + + public synchronized String lastMessage() { + return lastMessage_; + } + + + public synchronized void transportAvailable(String mode, String message) { + policy_.transportAvailable(mode); + publish(message); + } + + public synchronized void permissionPending(String message) { + policy_.permissionPending(); + publish(message); + } + + public synchronized void switchingToAoap(String message) { + policy_.switchingToAoap(); + publish(message); + } + + public synchronized void connecting(String mode, String message) { + policy_.connecting(mode); + publish(message); + } + + public synchronized void active(String message) { + policy_.active(); + publish(message); + } + + public synchronized void disconnecting(String message) { + policy_.disconnecting(); + publish(message); + } + + public synchronized void userExited(String message) { + policy_.userExited(); + publish(message); + } + + public synchronized void detached(String message) { + policy_.detached(); + publish(message); + } + + public synchronized void detached(String mode, String message) { + if (mode != null && policy_.mode() != null && !mode.equals(policy_.mode())) { + return; + } + detached(message); + } + + public synchronized void failed(String message) { + policy_.failed(); + publish(message); + } + + private void publish(String message) { + lastMessage_ = message; + Intent intent = new Intent(ACTION_STATE_CHANGED); + intent.putExtra(EXTRA_STATE, policy_.state().name()); + intent.putExtra(EXTRA_MODE, policy_.mode()); + intent.putExtra(EXTRA_MESSAGE, message); + broadcastManager_.sendBroadcast(intent); + } +} diff --git a/app/src/main/java/it/smg/hu/manager/ConnectionPolicy.java b/app/src/main/java/it/smg/hu/manager/ConnectionPolicy.java new file mode 100644 index 00000000..d72e06c9 --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/ConnectionPolicy.java @@ -0,0 +1,64 @@ +package it.smg.hu.manager; + +/** Pure connection policy so manual start and exit behavior can be tested without Android hardware. */ +public final class ConnectionPolicy { + private ConnectionState state = ConnectionState.IDLE; + private String mode; + + public ConnectionState state() { + return state; + } + + public String mode() { + return mode; + } + + public boolean isManualStartAllowed() { + // A start request during permission, AOAP negotiation or native + // teardown creates an overlapping session. Only a prepared transport + // (IDLE) or a deliberately stopped, still-prepared transport (EXITED) + // may launch PlayerActivity. + return state == ConnectionState.IDLE || state == ConnectionState.EXITED; + } + + public void transportAvailable(String connectionMode) { + mode = connectionMode; + state = ConnectionState.IDLE; + } + + public void permissionPending() { + state = ConnectionState.PERMISSION_PENDING; + } + + public void switchingToAoap() { + state = ConnectionState.AOAP_SWITCHING; + } + + public void connecting(String connectionMode) { + if (connectionMode != null) { + mode = connectionMode; + } + state = ConnectionState.CONNECTING; + } + + public void active() { + state = ConnectionState.ACTIVE; + } + + public void disconnecting() { + state = ConnectionState.DISCONNECTING; + } + + public void userExited() { + state = ConnectionState.EXITED; + } + + public void detached() { + mode = null; + state = ConnectionState.IDLE; + } + + public void failed() { + state = ConnectionState.ERROR; + } +} diff --git a/app/src/main/java/it/smg/hu/manager/ConnectionState.java b/app/src/main/java/it/smg/hu/manager/ConnectionState.java new file mode 100644 index 00000000..30b31695 --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/ConnectionState.java @@ -0,0 +1,12 @@ +package it.smg.hu.manager; + +public enum ConnectionState { + IDLE, + PERMISSION_PENDING, + AOAP_SWITCHING, + CONNECTING, + DISCONNECTING, + ACTIVE, + EXITED, + ERROR +} diff --git a/app/src/main/java/it/smg/hu/manager/HondaConnectManager.java b/app/src/main/java/it/smg/hu/manager/HondaConnectManager.java index 66d4c99a..d81d2b9f 100644 --- a/app/src/main/java/it/smg/hu/manager/HondaConnectManager.java +++ b/app/src/main/java/it/smg/hu/manager/HondaConnectManager.java @@ -147,7 +147,7 @@ public void onServiceConnected(ComponentName name, IBinder service) { registerSteeringMenuCallback(); - if (pControl_.authType != Constants.AUTH_TYPE_PREINSTALL){ + if (!isPreinstall()){ notifySteeringMenuDispMode(1); } @@ -203,7 +203,7 @@ public void onServiceDisconnected(ComponentName name) { } public int mediaAudioStream(ChannelId audioChannel){ - if (pControl_.authType == Constants.AUTH_TYPE_PREINSTALL){ + if (isPreinstall()){ switch (audioChannel) { case SPEECH_AUDIO: case SYSTEM_AUDIO: @@ -229,11 +229,11 @@ public void adjustPermission(){ } public void requestAudioFocus(){ - if (Log.isDebug()) Log.d(TAG, "requestAudioFocus -> app with auth " + pControl_.authType); + if (Log.isDebug()) Log.d(TAG, "requestAudioFocus -> app with auth " + authType()); if (Log.isVerbose()) Log.v(TAG, "requestAudioFocus modeMgr audio hasAudioFocus= " + hasAudioFocus_); // If AUTH_TYPE <> preinstall the app has already audio focus - if (pControl_.authType == Constants.AUTH_TYPE_PREINSTALL && !hasAudioFocus_) { + if (isPreinstall() && modeMgrManager_ != null && !hasAudioFocus_) { int idx = settings_.advanced.modeMgrAudioIdx(); int ret; @@ -255,11 +255,11 @@ public void requestAudioFocus(){ } public void releaseAudioFocus(){ - if (Log.isDebug()) Log.d(TAG, "releaseAudioFocus -> app with auth " + pControl_.authType); + if (Log.isDebug()) Log.d(TAG, "releaseAudioFocus -> app with auth " + authType()); if (Log.isVerbose()) Log.v(TAG, "releaseAudioFocus modeMgr audio hasAudioFocus= " + hasAudioFocus_); // If AUTH_TYPE <> preinstall the app has already audio focus - if (pControl_.authType == Constants.AUTH_TYPE_PREINSTALL && hasAudioFocus_) { + if (isPreinstall() && modeMgrManager_ != null && hasAudioFocus_) { int idx = settings_.advanced.modeMgrAudioIdx(); int ret; @@ -273,17 +273,17 @@ public void releaseAudioFocus(){ public void increaseVolume(){ if (Log.isVerbose()) Log.v(TAG, "increaseVolume"); - modeMgrManager_.reqModeMgrSteeringVolCmd(true); + if (modeMgrManager_ != null) modeMgrManager_.reqModeMgrSteeringVolCmd(true); } public void decreaseVolume(){ if (Log.isVerbose()) Log.v(TAG, "decreaseVolume"); - modeMgrManager_.reqModeMgrSteeringVolCmd(false); + if (modeMgrManager_ != null) modeMgrManager_.reqModeMgrSteeringVolCmd(false); } // Used in onCreate public void initialize(){ - if (Log.isDebug()) Log.d(TAG, "initialize -> app with auth " + pControl_.authType); + if (Log.isDebug()) Log.d(TAG, "initialize -> app with auth " + authType()); bindToEcNcService(); bindToWheelService(); @@ -292,9 +292,9 @@ public void initialize(){ // Used in onResume public void initAudioBinding(){ - if (Log.isDebug()) Log.d(TAG, "initAudioBinding -> app with auth " + pControl_.authType); + if (Log.isDebug()) Log.d(TAG, "initAudioBinding -> app with auth " + authType()); // if app has THIRD_PARTY auth will have exclusive audio focus, only bind wheel service - if (pControl_.authType == Constants.AUTH_TYPE_PREINSTALL){ + if (isPreinstall()){ if (Log.isVerbose()) Log.v(TAG, "initAudioBinding -> app auth = preinstall -> register ModeMgr and SW callback"); registerModeMgrCallback(); @@ -316,19 +316,19 @@ public void initAudioBinding(){ // Used in onPause (app in background) public void sendToBackground(){ - if (Log.isDebug()) Log.d(TAG, "sendToBackground -> app with auth " + pControl_.authType + " unregister SW callback"); + if (Log.isDebug()) Log.d(TAG, "sendToBackground -> app with auth " + authType() + " unregister SW callback"); unregisterSteeringMenuCallback(); unregisterStateMgrCallback(); notifySteeringMenuDispMode(0); } public void endAudioBinding(){ - if (Log.isDebug()) Log.d(TAG, "endAudioBinding -> app with auth " + pControl_.authType); + if (Log.isDebug()) Log.d(TAG, "endAudioBinding -> app with auth " + authType()); stopMicSession(); unbindFromEcNcService(); - if (pControl_.authType == Constants.AUTH_TYPE_PREINSTALL){ + if (isPreinstall()){ if (Log.isDebug()) Log.d(TAG, "endAudioBinding -> auth PREINSTALL -> release audio and unregister modemgr callback"); releaseAudioFocus(); unregisterModeMgrCallback(); @@ -341,7 +341,7 @@ public void endAudioBinding(){ private void notifySteeringMenuDispMode(int mode){ if (Log.isDebug()) Log.d(TAG, "notifySteeringMenuDispMode -> boundToSteeringMenuService= " + boundToSteeringMenuService_); - if (boundToSteeringMenuService_) { + if (boundToSteeringMenuService_ && steeringMenuServiceIface_ != null) { try { int idx = settings_.advanced.steeringWheelIdx(); if (idx > 0) { @@ -350,6 +350,8 @@ private void notifySteeringMenuDispMode(int mode){ } } catch (RemoteException e) { Log.e(TAG, "Error registering", e); + } catch (RuntimeException e) { + Log.e(TAG, "Unexpected error notifySteeringMenuDispMode", e); } } } @@ -365,6 +367,10 @@ private void registerSteeringMenuCallback(){ } } catch (RemoteException e) { Log.e(TAG, "Error registerCallbackEx", e); + steeringMenuServiceCallback_ = null; + } catch (RuntimeException e) { + Log.e(TAG, "Unexpected error registerCallbackEx", e); + steeringMenuServiceCallback_ = null; } } @@ -379,6 +385,10 @@ private void unregisterSteeringMenuCallback(){ } } catch (RemoteException e) { Log.e(TAG, "Error unregisterCallbackEx", e); + steeringMenuServiceCallback_ = null; + } catch (RuntimeException e) { + Log.e(TAG, "Unexpected error unregisterCallbackEx", e); + steeringMenuServiceCallback_ = null; } } @@ -495,6 +505,9 @@ private void unregisterModeMgrCallback() { int idx = settings_.advanced.modeMgrAudioIdx(); if (Log.isVerbose()) Log.v(TAG, "unregisterModeMgrCallback idx " + idx); + if (modeMgrManager_ == null) { + return; + } int ret = modeMgrManager_.unregisterModeMgrCallback(idx); if (Log.isVerbose()) Log.v(TAG, "unregisterModeMgrCallback ret " + ret); @@ -514,6 +527,14 @@ private void registerModeMgrCallback(){ } } + private boolean isPreinstall() { + return pControl_ != null && pControl_.authType == Constants.AUTH_TYPE_PREINSTALL; + } + + private String authType() { + return pControl_ == null ? "unavailable" : String.valueOf(pControl_.authType); + } + private void registerStateMgrCallback() { if (Log.isDebug()) Log.d(TAG, "registerStateMgrCallback"); if (stateMgrManager_ != null && stateMgrServiceCallBack_ == null) { diff --git a/app/src/main/java/it/smg/hu/manager/HondaPlatform.java b/app/src/main/java/it/smg/hu/manager/HondaPlatform.java new file mode 100644 index 00000000..5d8d4297 --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/HondaPlatform.java @@ -0,0 +1,31 @@ +package it.smg.hu.manager; + +import android.content.Context; + +/** Keeps proprietary Honda APIs out of the generic-device startup path. */ +public final class HondaPlatform { + private static Context context_; + private static Boolean available_; + + private HondaPlatform() {} + + public static void init(Context context) { + context_ = context.getApplicationContext(); + available_ = null; + } + + public static boolean isAvailable() { + if (available_ != null) { + return available_; + } + boolean available = false; + try { + Class.forName("com.fujitsu_ten.displayaudio.whitelist.common.IWhiteList"); + available = context_ != null && context_.getSystemService("ModeMgrService") != null; + } catch (Throwable ignored) { + available = false; + } + available_ = available; + return available; + } +} diff --git a/app/src/main/java/it/smg/hu/manager/USBManager.java b/app/src/main/java/it/smg/hu/manager/USBManager.java index 2e4a6161..0e209af4 100644 --- a/app/src/main/java/it/smg/hu/manager/USBManager.java +++ b/app/src/main/java/it/smg/hu/manager/USBManager.java @@ -7,13 +7,19 @@ import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbManager; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import it.smg.hu.R; import it.smg.libs.common.Log; import it.smg.libs.aasdk.usb.LibUsb; import it.smg.libs.aasdk.usb.LibUsbDevice; @@ -21,13 +27,10 @@ public class USBManager { - private static final int USB_PID_ACC = 0x2D00; // Accessory 100 - private static final int USB_PID_ACC_ADB = 0x2D01; // Accessory + ADB 110 - private static final int USB_VID_GOO = 0x18D1; - private static final int ACC_REQ_GET_PROTOCOL = 51; private static final int ACC_REQ_SEND_STRING = 52; private static final int ACC_REQ_START = 53; + private static final int AOAP_TRANSFER_TIMEOUT_MS = 3000; private static final String MANUFACTURER = "Android"; private static final String MODEL = "Android Auto"; @@ -44,7 +47,16 @@ public class USBManager { private final Context ctx_; private final UsbManager usbManager_; - private LibUsbDevice usbDevice_; + private final Object deviceLock_ = new Object(); + private final ExecutorService usbExecutor_; + private final Handler mainHandler_; + private volatile LibUsbDevice usbDevice_; + private volatile UsbDevice lastDevice_; + private int attachedDeviceId_ = -1; + private int pendingPermissionDeviceId_ = -1; + private int aoapSourceDeviceId_ = -1; + private int aoapOperationId_; + private boolean aoapSwitching_; private final LocalBroadcastManager localBroadcastManager_; private static USBManager instance_; @@ -59,48 +71,464 @@ public static USBManager instance(){ } private USBManager(Context ctx){ - ctx_ = ctx; + ctx_ = ctx.getApplicationContext(); usbManager_ = (UsbManager) ctx_.getSystemService(Context.USB_SERVICE); localBroadcastManager_ = LocalBroadcastManager.getInstance(ctx_); + mainHandler_ = new Handler(Looper.getMainLooper()); + usbExecutor_ = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "ODA-USB-worker"); + thread.setDaemon(true); + return thread; + }); LibUsb.init(); libUsb_ = LibUsb.instance(); + ConnectionManager.init(ctx_); } public boolean checkDevice(UsbDevice device){ - if (Log.isInfo()) Log.i(TAG, "checkDevice"); + handleUsbAttached(device); + return checkAOAPDevice(device); + } + + /** + * Records a newly attached device without opening Android's permission UI. + * Permission is requested only after the user presses the USB action on the + * dashboard, avoiding the legacy UsbConfirmActivity/UsbPermissionActivity + * overlap that force-stops the selected package. + */ + public void onUsbDeviceDetected(UsbDevice device) { + if (device == null) { + return; + } + if (usbManager_.hasPermission(device)) { + handleUsbAttached(device); + return; + } + if (!UsbAccessoryIds.isAoap(device.getVendorId(), device.getProductId()) + && !UsbAccessoryIds.maybeSupportsAoap(device)) { + return; + } + synchronized (deviceLock_) { + lastDevice_ = device; + } + ConnectionManager.instance().transportAvailable("modeUSB", + ctx_.getString(R.string.connection_usb_detected)); + } + + public void handleUsbAttached(UsbDevice device){ + if (device == null) { + return; + } + if (Log.isInfo()) Log.i(TAG, "handleUsbAttached " + device); + boolean aoapDevice = checkAOAPDevice(device); + synchronized (deviceLock_) { + if (!aoapDevice && ((aoapSwitching_ && aoapSourceDeviceId_ != device.getDeviceId()) + || (usbDevice_ != null && attachedDeviceId_ != device.getDeviceId()))) { + if (Log.isInfo()) Log.i(TAG, "Ignoring unrelated USB device while Android Auto USB is active"); + return; + } + lastDevice_ = device; + } boolean deviceHasPermission = usbManager_.hasPermission(device); if (Log.isInfo()) Log.i(TAG, "deviceHasPermission " + deviceHasPermission); if (!deviceHasPermission) { - if (Log.isDebug()) Log.d(TAG, "Request permission"); - Intent i = new Intent(ACTION_USB_PERMISSION); - PendingIntent permissionIntent = PendingIntent.getBroadcast(ctx_, 0, i, PendingIntent.FLAG_ONE_SHOT); - usbManager_.requestPermission(device, permissionIntent); + requestPermission(device); + return; + } + + if (aoapDevice) { + attachAoapDevice(device); + return; + } + + synchronized (deviceLock_) { + if (aoapSwitching_ && aoapSourceDeviceId_ == device.getDeviceId()) { + return; + } + } + + if (!UsbAccessoryIds.maybeSupportsAoap(device)) { + // Storage sticks, hubs and the like share this port. Negotiating AOAP + // with them can only fail, and reporting that as a connection error + // buried the real state of the Android Auto link. + if (Log.isInfo()) Log.i(TAG, "Skipping AOAP switch for non-phone device " + device.getDeviceName()); + return; + } + + ConnectionManager.instance().transportAvailable("modeUSB", ctx_.getString(R.string.connection_usb_detected)); + beginAoapSwitch(device); + } + + public void onUsbPermissionResult(UsbDevice device, boolean granted) { + synchronized (deviceLock_) { + if (device == null || pendingPermissionDeviceId_ == device.getDeviceId()) { + pendingPermissionDeviceId_ = -1; + } + } + if (device == null) { + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_permission_device_error)); + return; + } + if (!granted) { + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_permission_denied)); + return; + } + handleUsbAttached(device); + } + + public void onUsbDetached(UsbDevice device) { + if (device == null) { + return; + } + + boolean expectedAoapReenumeration = false; + boolean activeDeviceDetached = false; + synchronized (deviceLock_) { + int deviceId = device.getDeviceId(); + if (pendingPermissionDeviceId_ == deviceId) { + pendingPermissionDeviceId_ = -1; + } + if (aoapSwitching_ && aoapSourceDeviceId_ == deviceId) { + expectedAoapReenumeration = true; + if (lastDevice_ != null && lastDevice_.getDeviceId() == deviceId) { + lastDevice_ = null; + } + } else if (attachedDeviceId_ == deviceId + || (lastDevice_ != null && lastDevice_.getDeviceId() == deviceId)) { + invalidateAoapOperationLocked(); + closeUsbDeviceLocked(); + lastDevice_ = null; + activeDeviceDetached = true; + } + } + + if (expectedAoapReenumeration) { + if (Log.isInfo()) Log.i(TAG, "USB source detached while waiting for AOAP re-enumeration"); + return; } + if (activeDeviceDetached) { + ConnectionManager.instance().detached("modeUSB", ctx_.getString(R.string.connection_usb_disconnected)); + localBroadcastManager_.sendBroadcast(new Intent(DETACH_AOAP_DEVICE)); + } + } + + private void requestPermission(UsbDevice device) { + int deviceId = device.getDeviceId(); + synchronized (deviceLock_) { + if (pendingPermissionDeviceId_ == deviceId) { + return; + } + pendingPermissionDeviceId_ = deviceId; + } + + if (Log.isDebug()) Log.d(TAG, "Request permission for USB device " + deviceId); + Intent permissionIntent = new Intent(ctx_, UsbPermissionReceiver.class); + permissionIntent.setAction(ACTION_USB_PERMISSION); + PendingIntent pendingIntent = PendingIntent.getBroadcast(ctx_, deviceId, permissionIntent, + PendingIntent.FLAG_ONE_SHOT); + ConnectionManager.instance().permissionPending(ctx_.getString(R.string.connection_usb_permission)); + try { + usbManager_.requestPermission(device, pendingIntent); + } catch (RuntimeException error) { + synchronized (deviceLock_) { + if (pendingPermissionDeviceId_ == deviceId) { + pendingPermissionDeviceId_ = -1; + } + } + Log.e(TAG, "Unable to request USB permission", error); + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_permission_device_error)); + } + } - boolean isAoap = checkAOAPDevice(device); - if (!isAoap) { - if (Log.isInfo()) Log.i(TAG, "Request aoap"); - requestAOAP(device); - } else { - if (Log.isDebug()) Log.d(TAG, "Device is aoap"); - usbDevice_ = libUsb_.createDevice(device, usbManager_); + private void beginAoapSwitch(UsbDevice device) { + final int operationId; + final int sourceDeviceId = device.getDeviceId(); + synchronized (deviceLock_) { + if (aoapSwitching_ && aoapSourceDeviceId_ == sourceDeviceId) { + return; + } + invalidateAoapOperationLocked(); + aoapSwitching_ = true; + aoapSourceDeviceId_ = sourceDeviceId; + operationId = aoapOperationId_; + } + + ConnectionManager.instance().switchingToAoap(ctx_.getString(R.string.connection_usb_preparing)); + usbExecutor_.execute(() -> negotiateAoap(operationId, sourceDeviceId)); + } - if (Log.isDebug()) Log.d(TAG, "send local broadcast ATTACH_AOAP_DEVICE"); - Intent aoapDeviceIntent = new Intent(ATTACH_AOAP_DEVICE); - aoapDeviceIntent.putExtra(UsbManager.EXTRA_DEVICE, device); - localBroadcastManager_.sendBroadcast(aoapDeviceIntent); + private void negotiateAoap(int operationId, int sourceDeviceId) { + try { + for (int attempt = 0; attempt < UsbRetryPolicy.NEGOTIATION_ATTEMPTS; attempt++) { + if (!isAoapOperationCurrent(operationId)) { + return; + } + UsbDevice device = findDeviceById(sourceDeviceId); + if (device == null) { + break; + } + if (checkAOAPDevice(device)) { + mainHandler_.post(() -> handleUsbAttached(device)); + return; + } + if (requestAOAP(device)) { + break; + } + if (attempt + 1 < UsbRetryPolicy.NEGOTIATION_ATTEMPTS) { + SystemClock.sleep(UsbRetryPolicy.negotiationDelayMs(attempt)); + } + } + } catch (Throwable error) { + Log.e(TAG, "AOAP negotiation failed", error); + mainHandler_.post(() -> ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_switch_error))); + return; } + scheduleAoapScan(operationId, 0); + } - return isAoap; + private void scheduleAoapScan(int operationId, int scanAttempt) { + if (!isAoapOperationCurrent(operationId)) { + return; + } + mainHandler_.postDelayed(() -> scanForAoapDevice(operationId, scanAttempt), + UsbRetryPolicy.reenumerationDelayMs(scanAttempt)); + } + + private void scanForAoapDevice(int operationId, int scanAttempt) { + if (!isAoapOperationCurrent(operationId)) { + return; + } + + UsbDevice accessory = findAoapDevice(); + if (accessory != null) { + synchronized (deviceLock_) { + if (!isAoapOperationCurrentLocked(operationId)) { + return; + } + invalidateAoapOperationLocked(); + } + handleUsbAttached(accessory); + return; + } + + if (scanAttempt + 1 < UsbRetryPolicy.REENUMERATION_ATTEMPTS) { + scheduleAoapScan(operationId, scanAttempt + 1); + return; + } + + synchronized (deviceLock_) { + if (!isAoapOperationCurrentLocked(operationId)) { + return; + } + invalidateAoapOperationLocked(); + } + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_switch_error)); + } + + private void attachAoapDevice(UsbDevice device) { + LibUsbDevice attachedDevice; + synchronized (deviceLock_) { + if (attachedDeviceId_ == device.getDeviceId() && usbDevice_ != null) { + return; + } + invalidateAoapOperationLocked(); + closeUsbDeviceLocked(); + lastDevice_ = device; + pendingPermissionDeviceId_ = -1; + attachedDevice = libUsb_.createDevice(device, usbManager_); + usbDevice_ = attachedDevice; + attachedDeviceId_ = attachedDevice == null ? -1 : device.getDeviceId(); + } + + if (attachedDevice == null) { + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_switch_error)); + return; + } + ConnectionManager.instance().transportAvailable("modeUSB", ctx_.getString(R.string.connection_usb_ready)); + Intent aoapDeviceIntent = new Intent(ATTACH_AOAP_DEVICE); + aoapDeviceIntent.putExtra(UsbManager.EXTRA_DEVICE, device); + localBroadcastManager_.sendBroadcast(aoapDeviceIntent); + } + + private UsbDevice findDeviceById(int deviceId) { + for (UsbDevice device : usbManager_.getDeviceList().values()) { + if (device.getDeviceId() == deviceId) { + return device; + } + } + return null; + } + + private UsbDevice findAoapDevice() { + for (UsbDevice device : usbManager_.getDeviceList().values()) { + if (checkAOAPDevice(device)) { + return device; + } + } + return null; + } + + private UsbDevice findPermittedAoapDevice() { + for (UsbDevice device : usbManager_.getDeviceList().values()) { + if (usbManager_.hasPermission(device) && checkAOAPDevice(device)) { + return device; + } + } + return null; + } + + private boolean isAoapOperationCurrent(int operationId) { + synchronized (deviceLock_) { + return isAoapOperationCurrentLocked(operationId); + } + } + + private boolean isAoapOperationCurrentLocked(int operationId) { + return aoapSwitching_ && aoapOperationId_ == operationId; + } + + private void invalidateAoapOperationLocked() { + aoapOperationId_++; + aoapSwitching_ = false; + aoapSourceDeviceId_ = -1; + } + + private void closeUsbDeviceLocked() { + LibUsbDevice device = usbDevice_; + usbDevice_ = null; + attachedDeviceId_ = -1; + if (device == null) { + return; + } + try { + device.close(); + } catch (Throwable error) { + Log.e(TAG, "Error closing stale USB device", error); + } + } + + /** Resets a session after its transfers have stopped but before it is deleted. */ + public void resetSession(LibUsbDevice sessionDevice) { + if (sessionDevice == null) { + return; + } + try { + sessionDevice.reset(); + } catch (Throwable error) { + Log.e(TAG, "Error resetting completed USB session", error); + } + } + + /** + * Discards a completed transport. Delayed scans create a fresh wrapper only + * after Android has processed a possible reset-driven re-enumeration. + */ + public void finishSession(LibUsbDevice sessionDevice) { + if (sessionDevice == null) { + return; + } + synchronized (deviceLock_) { + if (usbDevice_ == sessionDevice) { + closeUsbDeviceLocked(); + lastDevice_ = null; + pendingPermissionDeviceId_ = -1; + } + } + mainHandler_.postDelayed(this::rescanAttachedDevices, 750L); + mainHandler_.postDelayed(this::rescanAttachedDevices, 1500L); + } + + public synchronized boolean recoverConnection() { + if (Log.isInfo()) Log.i(TAG, "recoverConnection"); + // Publish the busy state before touching the device. The dashboard + // keeps its action disabled until a fresh AOAP accessory is attached. + ConnectionManager.instance().switchingToAoap(ctx_.getString(R.string.connection_usb_preparing)); + synchronized (deviceLock_) { + invalidateAoapOperationLocked(); + closeUsbDeviceLocked(); + lastDevice_ = null; + pendingPermissionDeviceId_ = -1; + } + + UsbDevice fallbackDevice = null; + for (UsbDevice device : usbManager_.getDeviceList().values()) { + if (checkAOAPDevice(device) && usbManager_.hasPermission(device)) { + attachAoapDevice(device); + return true; + } + if (fallbackDevice == null && UsbAccessoryIds.maybeSupportsAoap(device)) { + fallbackDevice = device; + } + } + + if (fallbackDevice != null) { + handleUsbAttached(fallbackDevice); + return true; + } + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_usb_unavailable)); + return false; } public LibUsbDevice aoapDevice(){ return usbDevice_; } + /** Recovers a missed attach broadcast without starting the Android Auto session. */ + public boolean rescanAttachedDevices() { + UsbDevice accessory = findPermittedAoapDevice(); + if (accessory != null) { + handleUsbAttached(accessory); + return true; + } + + UsbDevice previous = lastDevice_ == null ? null : findDeviceById(lastDevice_.getDeviceId()); + if (previous != null && usbManager_.hasPermission(previous)) { + handleUsbAttached(previous); + return true; + } + + UsbDevice onlyDevice = null; + for (UsbDevice device : usbManager_.getDeviceList().values()) { + if (!UsbAccessoryIds.isAoap(device.getVendorId(), device.getProductId()) + && !UsbAccessoryIds.maybeSupportsAoap(device)) { + continue; + } + if (onlyDevice != null) { + return false; + } + onlyDevice = device; + } + if (onlyDevice != null) { + onUsbDeviceDetected(onlyDevice); + return true; + } + return false; + } + + /** Opens the current accessory with bounded recovery; call only from a worker thread. */ + public LibUsbDevice openAoapDeviceWithRetry() { + for (int attempt = 0; attempt < UsbRetryPolicy.NEGOTIATION_ATTEMPTS; attempt++) { + LibUsbDevice device = aoapDevice(); + if (device != null) { + try { + if (device.open()) { + return device; + } + } catch (Throwable error) { + Log.e(TAG, "Error opening AOAP device", error); + } + } + if (attempt + 1 < UsbRetryPolicy.NEGOTIATION_ATTEMPTS) { + recoverConnection(); + SystemClock.sleep(UsbRetryPolicy.negotiationDelayMs(attempt)); + } + } + return null; + } + private boolean checkAOAPDevice(UsbDevice device){ - if (device.getVendorId() == USB_VID_GOO && (device.getProductId() == USB_PID_ACC || device.getProductId() == USB_PID_ACC_ADB)) { + if (device != null && UsbAccessoryIds.isAoap(device.getVendorId(), device.getProductId())) { if (Log.isInfo()) Log.i(TAG, "Found aop device"); return true; } @@ -124,7 +552,7 @@ public void searchForAoapDevice(){ if (usbManager_.getDeviceList() != null) { for(UsbDevice dev : usbManager_.getDeviceList().values()){ if (checkAOAPDevice(dev)){ - usbDevice_ = libUsb_.createDevice(dev, usbManager_); + handleUsbAttached(dev); return; } } @@ -138,7 +566,7 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "switch to OAP"); byte[] buffer = new byte[2]; - int len = usbConnection.controlTransfer(UsbConstants.USB_DIR_IN | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_GET_PROTOCOL, 0, 0, buffer, 2, 10000); + int len = usbConnection.controlTransfer(UsbConstants.USB_DIR_IN | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_GET_PROTOCOL, 0, 0, buffer, 2, AOAP_TRANSFER_TIMEOUT_MS); if (len != 2) { Log.e(TAG, "Error controlTransfer GET_PROTOCOL len: " + len); return false; @@ -152,7 +580,7 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "Send Manufacter " + MANUFACTURER); buffer = (MANUFACTURER + "\0").getBytes(); - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 0, buffer, buffer.length, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 0, buffer, buffer.length, AOAP_TRANSFER_TIMEOUT_MS); if (len != buffer.length) { Log.e(TAG, "Error sending Manufacter, len= " + len + " expected= " + buffer.length); return false; @@ -160,7 +588,7 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "Send Model " + MODEL); buffer = (MODEL + "\0").getBytes(); - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 1, buffer, buffer.length, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 1, buffer, buffer.length, AOAP_TRANSFER_TIMEOUT_MS); if (len != buffer.length) { Log.e(TAG, "Error sending Model, len= " + len + " expected= " + buffer.length); return false; @@ -168,7 +596,7 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "Send Desc " + DESC); buffer = (DESC + "\0").getBytes(); - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 2, buffer, buffer.length, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 2, buffer, buffer.length, AOAP_TRANSFER_TIMEOUT_MS); if (len != buffer.length) { Log.e(TAG, "Error sending Desc, len= " + len + " expected= " + buffer.length); return false; @@ -176,7 +604,7 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "Send Version " + VERSION); buffer = (VERSION + "\0").getBytes(); - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 0, buffer, buffer.length, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 3, buffer, buffer.length, AOAP_TRANSFER_TIMEOUT_MS); if (len != buffer.length) { Log.e(TAG, "Error sending Version, len= " + len + " expected= " + buffer.length); return false; @@ -184,7 +612,7 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "Send URI " + URI); buffer = (URI + "\0").getBytes(); - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 0, buffer, buffer.length, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 4, buffer, buffer.length, AOAP_TRANSFER_TIMEOUT_MS); if (len != buffer.length) { Log.e(TAG, "Error sending Manufacter, len= " + len + " expected= " + buffer.length); return false; @@ -192,14 +620,14 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "Send Serial " + SERIAL); buffer = (SERIAL + "\0").getBytes(); - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 0, buffer, buffer.length, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_SEND_STRING, 0, 5, buffer, buffer.length, AOAP_TRANSFER_TIMEOUT_MS); if (len != buffer.length) { Log.e(TAG, "Error sending Serial, len= " + len + " expected= " + buffer.length); return false; } if (Log.isDebug()) Log.d(TAG, "Sending acc start"); // Send accessory start request. Device should re-enumerate as an accessory. - len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_START, 0, 0, null, 0, 10000); + len = usbConnection.controlTransfer(UsbConstants.USB_DIR_OUT | UsbConstants.USB_TYPE_VENDOR, ACC_REQ_START, 0, 0, null, 0, AOAP_TRANSFER_TIMEOUT_MS); if (len != 0) { Log.e(TAG, "Error in accessory start request"); return false; @@ -207,6 +635,9 @@ public boolean requestAOAP(UsbDevice device){ if (Log.isDebug()) Log.d(TAG, "switchOAP everything OK"); return true; + } catch (Exception error) { + Log.e(TAG, "controlTransfer failed during AOAP negotiation", error); + return false; } finally { if (Log.isDebug()) Log.d(TAG, "Closing connection"); usbConnection.close(); diff --git a/app/src/main/java/it/smg/hu/manager/UsbAccessoryIds.java b/app/src/main/java/it/smg/hu/manager/UsbAccessoryIds.java new file mode 100644 index 00000000..9d8758b0 --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/UsbAccessoryIds.java @@ -0,0 +1,74 @@ +package it.smg.hu.manager; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; + +/** Android Open Accessory identifiers accepted by the legacy head unit. */ +final class UsbAccessoryIds { + static final int GOOGLE_VENDOR_ID = 0x18D1; + static final int ACCESSORY = 0x2D00; + static final int ACCESSORY_ADB = 0x2D01; + static final int ACCESSORY_AUDIO = 0x2D04; + static final int ACCESSORY_AUDIO_ADB = 0x2D05; + + private UsbAccessoryIds() {} + + static boolean isAoap(int vendorId, int productId) { + if (vendorId != GOOGLE_VENDOR_ID) { + return false; + } + return productId == ACCESSORY || productId == ACCESSORY_ADB + || productId == ACCESSORY_AUDIO || productId == ACCESSORY_AUDIO_ADB; + } + + /** + * Whether it is worth asking this device to switch into accessory mode. + * + * The head unit's own USB port is shared with mass storage: the log volume, + * media sticks and card readers all show up here. Sending the AOAP + * GET_PROTOCOL control request to those always fails, and because every + * attach/recover pass retried it the failures surfaced to the user as a + * stream of USB connection errors. Devices that only expose interfaces a + * phone never uses are skipped instead. + */ + static boolean maybeSupportsAoap(UsbDevice device) { + if (device == null) { + return false; + } + + int interfaceCount = device.getInterfaceCount(); + int[] interfaceClasses = new int[interfaceCount]; + for (int i = 0; i < interfaceCount; i++) { + interfaceClasses[i] = device.getInterface(i).getInterfaceClass(); + } + return maybeSupportsAoap(interfaceClasses); + } + + /** @see #maybeSupportsAoap(UsbDevice) */ + static boolean maybeSupportsAoap(int[] interfaceClasses) { + if (interfaceClasses == null || interfaceClasses.length == 0) { + // Nothing to judge by - let the negotiation decide. + return true; + } + + for (int interfaceClass : interfaceClasses) { + if (!isNonPhoneInterface(interfaceClass)) { + return true; + } + } + return false; + } + + /** + * Deliberately a very short list. A phone can legitimately appear as almost + * anything depending on its USB mode - MTP and MIDI look vendor-specific, + * PTP is still-image class, and USB tethering shows up as CDC/RNDIS - so + * excluding those classes would risk ignoring a real phone. Only classes a + * phone never presents on its own are listed here. + */ + private static boolean isNonPhoneInterface(int interfaceClass) { + return interfaceClass == UsbConstants.USB_CLASS_MASS_STORAGE + || interfaceClass == UsbConstants.USB_CLASS_HUB + || interfaceClass == UsbConstants.USB_CLASS_PRINTER; + } +} diff --git a/app/src/main/java/it/smg/hu/manager/UsbPermissionReceiver.java b/app/src/main/java/it/smg/hu/manager/UsbPermissionReceiver.java new file mode 100644 index 00000000..b95c9a1a --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/UsbPermissionReceiver.java @@ -0,0 +1,26 @@ +package it.smg.hu.manager; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +/** Receives permission and detach broadcasts even while MainActivity is not visible. */ +public class UsbPermissionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + USBManager manager = USBManager.instance(); + if (manager == null || intent == null) { + return; + } + String action = intent.getAction(); + if (USBManager.ACTION_USB_PERMISSION.equals(action)) { + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + boolean granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); + manager.onUsbPermissionResult(device, granted); + } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { + manager.onUsbDetached(intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)); + } + } +} diff --git a/app/src/main/java/it/smg/hu/manager/UsbRetryPolicy.java b/app/src/main/java/it/smg/hu/manager/UsbRetryPolicy.java new file mode 100644 index 00000000..126a52c3 --- /dev/null +++ b/app/src/main/java/it/smg/hu/manager/UsbRetryPolicy.java @@ -0,0 +1,17 @@ +package it.smg.hu.manager; + +/** Bounded timings for legacy USB negotiation and AOAP re-enumeration. */ +final class UsbRetryPolicy { + static final int NEGOTIATION_ATTEMPTS = 3; + static final int REENUMERATION_ATTEMPTS = 9; + + private UsbRetryPolicy() {} + + static long negotiationDelayMs(int failedAttempt) { + return failedAttempt <= 0 ? 250L : 600L; + } + + static long reenumerationDelayMs(int scanAttempt) { + return 350L + Math.min(Math.max(scanAttempt, 0) * 250L, 1000L); + } +} diff --git a/app/src/main/java/it/smg/hu/manager/WIFIManager.java b/app/src/main/java/it/smg/hu/manager/WIFIManager.java index 03382e1a..d4508101 100644 --- a/app/src/main/java/it/smg/hu/manager/WIFIManager.java +++ b/app/src/main/java/it/smg/hu/manager/WIFIManager.java @@ -12,7 +12,9 @@ import java.net.InetAddress; import java.net.UnknownHostException; +import it.smg.hu.R; import it.smg.libs.common.Log; +import it.smg.hu.config.Settings; public class WIFIManager { @@ -57,6 +59,9 @@ private String networkSSID(){ private String ipAddress(){ WifiManager wifi = (WifiManager) ctx_.getSystemService(Context.WIFI_SERVICE); DhcpInfo d = wifi.getDhcpInfo(); + if (d == null || d.gateway == 0) { + return null; + } byte[] addressBytes = {(byte) (0xff & d.gateway), (byte) (0xff & (d.gateway >> 8)), @@ -73,20 +78,29 @@ private String ipAddress(){ private boolean isConnected(){ ConnectivityManager connManager = (ConnectivityManager) ctx_.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo wifi = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); - return wifi.isConnected(); + return wifi != null && wifi.isConnected(); } public void checkNetwork(){ synchronized (this) { + if (!Settings.instance().advanced.enableWiFi()) { + return; + } boolean isConnected = isConnected(); String networkSSID_; if (isConnected) { ipAddress_ = ipAddress(); networkSSID_ = networkSSID(); + if (ipAddress_ == null) { + ConnectionManager.instance().failed(ctx_.getString(R.string.connection_hotspot_gateway_error)); + return; + } if (Log.isDebug()) Log.d(TAG, "Network with ip: " + ipAddress_); if (Log.isDebug()) Log.d(TAG, "Network with SSID: " + networkSSID_); + ConnectionManager.instance().transportAvailable("modeWifi", + ctx_.getString(R.string.connection_hotspot_ready, networkSSID_)); Intent connectWifiIntent = new Intent(CONNECT_WIFI); connectWifiIntent.putExtra(EXTRA_SSID, networkSSID_); localBroadcastManager_.sendBroadcast(connectWifiIntent); @@ -103,6 +117,7 @@ public void checkNetwork(){ Intent disconnectWifiIntent = new Intent(DISCONNECT_WIFI); localBroadcastManager_.sendBroadcast(disconnectWifiIntent); + ConnectionManager.instance().detached("modeWifi", ctx_.getString(R.string.connection_hotspot_disconnected)); } } } diff --git a/app/src/main/java/it/smg/hu/projection/AudioCodec.java b/app/src/main/java/it/smg/hu/projection/AudioCodec.java index 4a72945e..810455b3 100644 --- a/app/src/main/java/it/smg/hu/projection/AudioCodec.java +++ b/app/src/main/java/it/smg/hu/projection/AudioCodec.java @@ -5,8 +5,9 @@ import android.media.AudioTrack; import java.nio.ByteBuffer; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import it.smg.libs.common.Log; @@ -15,15 +16,19 @@ public class AudioCodec implements IAudioCodec, Runnable { private static final String TAG = "AudioCodec"; private final String name_; - private AudioTrack audioTrack_; private final int streamType_; private final int sampleRate_; private final int channelConfig_; private final int sampleSize_; protected AtomicBoolean running_; - private final Queue queue_; - private Thread codecThread_; + private final BlockingQueue queue_; + /** + * The thread that currently owns playback. Written by start()/stop() on the + * session threads and read by the playback loop to notice it has been + * superseded, so it is published explicitly. + */ + private volatile Thread codecThread_; public AudioCodec(String name, int streamType, int sampleRate, int channelConfig, int sampleSize){ name_ = name; @@ -33,7 +38,7 @@ public AudioCodec(String name, int streamType, int sampleRate, int channelConfig sampleSize_ = sampleSize; running_ = new AtomicBoolean(false); - queue_ = new ConcurrentLinkedQueue<>(); + queue_ = new ArrayBlockingQueue<>(32); } private static int channels2num(int channels){ @@ -67,22 +72,32 @@ public static int sampleSizeFromInt(int sampleSize){ @Override public void write(ByteBuffer buffer, long timestamp) { if (Log.isVerbose()) Log.v(TAG, "buffer size: " + buffer.limit()); - final int size = buffer.limit(); + ByteBuffer source = buffer.slice(); + final int size = source.remaining(); + if (size <= 0) { + return; + } final byte[] data = new byte[size]; - buffer.get(data); + source.get(data); - queue_.add(data); + if (!queue_.offer(data)) { + queue_.poll(); + queue_.offer(data); + } } @Override public void start() { if (Log.isInfo()) Log.i(TAG, "Start"); - codecThread_ = new Thread(this); - codecThread_.setName(name_); - codecThread_.start(); - - running_.set(true); + if (running_.getAndSet(true)) { + return; + } + Thread codecThread = new Thread(this); + codecThread.setName(name_); + // Published before start() so the new thread's own identity check passes. + codecThread_ = codecThread; + codecThread.start(); } @Override @@ -91,12 +106,18 @@ public void stop() { if (running_.get()) { running_.set(false); - if (codecThread_ != null){ + Thread codecThread = codecThread_; + // Cleared before joining, so a thread still blocked inside + // AudioTrack.write() sees it has been superseded and bails out on its + // own once the write returns, even if the join below times out. + codecThread_ = null; + if (codecThread != null){ try { - codecThread_.join(); - if (Log.isDebug()) Log.d(TAG + "_" + codecThread_.getName(), "thread joined"); - } catch (InterruptedException ignored) {} - codecThread_ = null; + codecThread.join(1000); + if (Log.isDebug()) Log.d(TAG + "_" + name_, "thread joined"); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } } queue_.clear(); @@ -110,44 +131,61 @@ public void delete() { @Override public void run() { - if (audioTrack_ == null) { - try { - int bufferSize_ = AudioTrack.getMinBufferSize(sampleRate_, channels2num(channelConfig_), sampleSizeFromInt(sampleSize_)); - if (Log.isInfo()) Log.i(TAG, "Buffer size " + bufferSize_ + "*2"); - audioTrack_ = new AudioTrack(streamType_, sampleRate_, channels2num(channelConfig_), sampleSizeFromInt(sampleSize_), bufferSize_ * 3, AudioTrack.MODE_STREAM); - } catch (Exception e) { - Log.e(TAG, "error in audiotrack creation", e); + // The AudioTrack is owned by this thread alone. It used to be a field, so + // a playback thread that outlived its stop() (the join below is bounded) + // could release the track a freshly started thread was already writing to. + final Thread self = Thread.currentThread(); + AudioTrack audioTrack; + try { + int bufferSize = AudioTrack.getMinBufferSize(sampleRate_, channels2num(channelConfig_), sampleSizeFromInt(sampleSize_)); + if (bufferSize <= 0) { + Log.e(TAG, "Invalid AudioTrack buffer size " + bufferSize); + running_.set(false); return; } - - if (Log.isInfo()) Log.i(TAG, "initialized"); + if (Log.isInfo()) Log.i(TAG, "Buffer size " + bufferSize + "*3"); + audioTrack = new AudioTrack(streamType_, sampleRate_, channels2num(channelConfig_), sampleSizeFromInt(sampleSize_), bufferSize * 3, AudioTrack.MODE_STREAM); + } catch (Exception e) { + Log.e(TAG, "error in audiotrack creation", e); + running_.set(false); + return; } - if (audioTrack_ != null) { + if (Log.isInfo()) Log.i(TAG, "initialized"); + + try { if (Log.isInfo()) Log.i(TAG, "starting audiotrack"); - audioTrack_.play(); - } + audioTrack.play(); - if (Log.isVerbose()) Log.v(TAG + "_" + codecThread_.getName(), "running thread"); - while (running_.get()) { - byte[] data = queue_.poll(); - if (data != null){ - audioTrack_.write(data, 0, data.length); + if (Log.isVerbose()) Log.v(TAG + "_" + name_, "running thread"); + // codecThread_ != self means stop() (or a restart) has moved on + // without us: give up playback instead of fighting the new thread. + while (running_.get() && codecThread_ == self) { + byte[] data = null; + try { + data = queue_.poll(50, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + if (data != null){ + audioTrack.write(data, 0, data.length); + } } - } - - if (audioTrack_ != null) { + } finally { if (Log.isInfo()) Log.i(TAG, "stop audiotrack"); - if (audioTrack_.getPlayState() == AudioTrack.PLAYSTATE_PLAYING) { - audioTrack_.flush(); - audioTrack_.stop(); + try { + if (audioTrack.getPlayState() == AudioTrack.PLAYSTATE_PLAYING) { + audioTrack.flush(); + audioTrack.stop(); + } + } catch (IllegalStateException e) { + Log.e(TAG, "error stopping audiotrack", e); } - audioTrack_.release(); - audioTrack_ = null; + audioTrack.release(); + if (Log.isInfo()) Log.i(TAG, "audiotrack released"); } - if (Log.isInfo()) Log.i(TAG, "audiotrack released"); - - if (Log.isVerbose()) Log.v(TAG + "_" + codecThread_.getName(), "thread ended"); + if (Log.isVerbose()) Log.v(TAG + "_" + name_, "thread ended"); } } diff --git a/app/src/main/java/it/smg/hu/projection/DayNightSensor.java b/app/src/main/java/it/smg/hu/projection/DayNightSensor.java index c7fdab80..aaee9fa3 100644 --- a/app/src/main/java/it/smg/hu/projection/DayNightSensor.java +++ b/app/src/main/java/it/smg/hu/projection/DayNightSensor.java @@ -22,7 +22,9 @@ public class DayNightSensor implements ISensor, LocationListener, HondaConnectMa private static final String TAG = "DayNightSensor"; private final Context context_; - private int currentState_; + // Written by the location/timer thread, read by isNight() from a native + // projection thread. + private volatile int currentState_; private TwilightCalculator twilightCalculator_; private LocationManager locationManager_; private HandlerThread handlerThread_; @@ -126,7 +128,13 @@ private Location getLastKnownLocation() { } private void startTimeCheck() { - handler_.postDelayed(timeCallback_, 60*1000); // 10*60*1000 + // Read once: this reschedules itself from the sensor thread while stop() + // can null the field from the teardown thread, which used to throw an NPE + // on a thread with no handler - killing the process mid-teardown. + Handler handler = handler_; + if (handler != null) { + handler.postDelayed(timeCallback_, 60*1000); // 10*60*1000 + } } @Override @@ -166,12 +174,19 @@ public void stop() { if (Log.isDebug()) Log.d(TAG, "remove location update"); locationManager_.removeUpdates(this); } - if (handler_ != null){ + Handler handler = handler_; + HandlerThread handlerThread = handlerThread_; + handler_ = null; + handlerThread_ = null; + if (handler != null){ if (Log.isDebug()) Log.d(TAG, "remove handler callbacks"); - handler_.removeCallbacks(timeCallback_); - handlerThread_.interrupt(); - handlerThread_ = null; - handler_ = null; + handler.removeCallbacks(timeCallback_); + } + if (handlerThread != null){ + // quit(), not interrupt(): a thread parked in MessageQueue.next() does + // not react to interruption, so the old code leaked one HandlerThread + // per session - which this hardware cannot afford. + handlerThread.quit(); } if (Log.isDebug()) Log.d(TAG, "done"); } diff --git a/app/src/main/java/it/smg/hu/projection/InputDevice.java b/app/src/main/java/it/smg/hu/projection/InputDevice.java index c312dfc6..71b55d8b 100644 --- a/app/src/main/java/it/smg/hu/projection/InputDevice.java +++ b/app/src/main/java/it/smg/hu/projection/InputDevice.java @@ -190,11 +190,11 @@ public boolean onKey(View v, int keyCode, KeyEvent event) { final int plusKeyCode = Settings.instance().keymap.key(KeymapFragment.KeyMap.PLUS.keyName() + "_code"); final int minusKeyCode = Settings.instance().keymap.key(KeymapFragment.KeyMap.MINUS.keyName() + "_code"); - if (keyCode == plusKeyCode){ + if (keyCode == plusKeyCode && Settings.instance().advanced.hondaIntegrationEnabled()){ if (event.getAction() == KeyEvent.ACTION_DOWN) { // increase/decrease volume only on action down to prevent double increase/decrease HondaConnectManager.instance().increaseVolume(); } - } else if (keyCode == minusKeyCode) { + } else if (keyCode == minusKeyCode && Settings.instance().advanced.hondaIntegrationEnabled()) { if (event.getAction() == KeyEvent.ACTION_DOWN) { // increase/decrease volume only on action down to prevent double increase/decrease HondaConnectManager.instance().decreaseVolume(); } diff --git a/app/src/main/java/it/smg/hu/projection/LightSensor.java b/app/src/main/java/it/smg/hu/projection/LightSensor.java index 154c1479..8e5d36f1 100644 --- a/app/src/main/java/it/smg/hu/projection/LightSensor.java +++ b/app/src/main/java/it/smg/hu/projection/LightSensor.java @@ -15,7 +15,9 @@ public class LightSensor implements ISensor, SensorEventListener { private final Context context_; private Listener listener_; - private int currentState_; + // Written by the sensor callback thread, read by isNight() from a native + // projection thread. + private volatile int currentState_; public LightSensor(Context ctx){ context_ = ctx; diff --git a/app/src/main/java/it/smg/hu/projection/NativeVideoOutput.java b/app/src/main/java/it/smg/hu/projection/NativeVideoOutput.java index 9c13d286..587c4f87 100644 --- a/app/src/main/java/it/smg/hu/projection/NativeVideoOutput.java +++ b/app/src/main/java/it/smg/hu/projection/NativeVideoOutput.java @@ -19,7 +19,18 @@ public class NativeVideoOutput extends VideoOutput implements Runnable { private static final String TAG = "NativeVideoOutput"; - private MediaCodec codec_; + /** + * Input-buffer wait, in microseconds. This runs on the transport thread that + * delivers video, so a long wait stalls the whole session - audio and control + * messages included. The previous 3 s meant a decoder hiccup froze everything; + * roughly two frames is enough to ride out jitter and still fail fast. + */ + private static final long cDequeueTimeoutUs = 66_000; + + // Same reasoning as OMXVideoOutput: write()/stop() are @Keep entry points + // reached from different native threads, so the codec reference has to be + // published safely and read into a local before use. + private volatile MediaCodec codec_; private volatile boolean configured_; private volatile boolean running_; private Thread codecThread_; @@ -57,9 +68,9 @@ public boolean init() { // format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 655360); codec_.configure(format, surface, null, 0); - codec_.start(); configured_ = true; running_ = true; + codec_.start(); codecThread_.start(); return true; } catch (IOException e) { @@ -70,21 +81,27 @@ public boolean init() { @Keep @Override public void write(long timestamp, ByteBuffer buf) { - if (configured_ && running_) { - int index = codec_.dequeueInputBuffer(3000000); - if (index >= 0) { - ByteBuffer buffer; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - buffer = codec_.getInputBuffers()[index]; - buffer.clear(); - } else { - buffer = codec_.getInputBuffer(index); - } - if (buffer != null) { - buffer.put(buf); - buffer.flip(); - codec_.queueInputBuffer(index, 0, buf.limit(), timestamp, 0); + MediaCodec codec = codec_; + if (configured_ && running_ && codec != null) { + try { + int index = codec.dequeueInputBuffer(cDequeueTimeoutUs); + if (index >= 0) { + ByteBuffer buffer; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + buffer = codec.getInputBuffers()[index]; + buffer.clear(); + } else { + buffer = codec.getInputBuffer(index); + } + if (buffer != null) { + buffer.put(buf); + buffer.flip(); + codec.queueInputBuffer(index, 0, buf.limit(), timestamp, 0); + } } + } catch (IllegalStateException e) { + // The codec was released underneath us by a concurrent stop(). + if (Log.isWarn()) Log.w(TAG, "dropping frame, codec no longer usable"); } } } @@ -97,14 +114,26 @@ public void stop() { running_ = false; configured_ = false; try { - codecThread_.join(); - } catch (InterruptedException ignored) {} + if (codecThread_ != null) { + codecThread_.join(1000); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } - codec_.flush(); - codec_.stop(); - codec_.release(); + // Cleared before release so a writer that is still in flight sees null + // instead of reaching into a freed codec. + MediaCodec codec = codec_; codec_ = null; - + if (codec != null) { + try { + codec.flush(); + codec.stop(); + } catch (IllegalStateException e) { + Log.e(TAG, "error stopping codec", e); + } + codec.release(); + } } } @@ -112,12 +141,35 @@ public void stop() { public void run() { MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); while (running_) { - if (configured_) { - int index = codec_.dequeueOutputBuffer(info, 10000); - if (index >= 0) { - if (Log.isVerbose()) Log.v(TAG, "outputBufferIndex: " + index); - // setting true is telling system to render frame onto Surface - codec_.releaseOutputBuffer(index, true); + MediaCodec codec = codec_; + if (configured_ && codec != null) { + try { + int index = codec.dequeueOutputBuffer(info, 10000); + if (index >= 0) { + if (Log.isVerbose()) Log.v(TAG, "outputBufferIndex: " + index); + if (Log.isVerbose()) { + ByteBuffer buffer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ? codec.getOutputBuffer(index) + : codec.getOutputBuffers()[index]; + Log.v(TAG, "outputBuffer: " + buffer); + } + + // setting true is telling system to render frame onto Surface + codec.releaseOutputBuffer(index, true); + } + } catch (IllegalStateException e) { + // Released by a concurrent stop(); the loop condition will pick + // that up on the next pass. + if (Log.isWarn()) Log.w(TAG, "output loop stopping, codec released"); + } + } else { + // Nothing to drain yet: without this the loop spins at 100% CPU, + // which this hardware cannot spare. + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; } } } diff --git a/app/src/main/java/it/smg/hu/projection/OMXVideoOutput.java b/app/src/main/java/it/smg/hu/projection/OMXVideoOutput.java index b1d28ec7..464046f8 100644 --- a/app/src/main/java/it/smg/hu/projection/OMXVideoOutput.java +++ b/app/src/main/java/it/smg/hu/projection/OMXVideoOutput.java @@ -12,11 +12,15 @@ public class OMXVideoOutput extends VideoOutput /*implements Runnable*/ { private static final String TAG = "OMXVideoOutput"; - private OMXVideoCodec videoCodec_; + // write() runs on a native io_service thread while stop() comes from the + // session teardown on another thread, so every field they share has to be + // published safely - otherwise the writer can still see running_ == true and + // reach into a codec that has already been shut down. + private volatile OMXVideoCodec videoCodec_; private int frameSize_; - private boolean configured_; - private boolean running_; + private volatile boolean configured_; + private volatile boolean running_; public OMXVideoOutput(SurfaceView surfaceView){ super(surfaceView); @@ -58,9 +62,14 @@ public boolean init() { @Keep @Override public void write(long timestamp, ByteBuffer data) { - if (configured_ && running_) { + // Read the codec once: stop() can null it out between the guard and the + // call, which used to be an NPE (or worse, a decode into a codec whose + // native side was already deleted) on every teardown while frames were + // still arriving. + OMXVideoCodec videoCodec = videoCodec_; + if (configured_ && running_ && videoCodec != null) { if (Log.isVerbose()) Log.v(TAG, "video message size: " + data.limit()); - videoCodec_.mediaDecode(timestamp, data, data.limit()); + videoCodec.mediaDecode(timestamp, data, data.limit()); } } @@ -71,9 +80,12 @@ public void stop() { if (running_) { running_ = false; configured_ = false; - videoCodec_.shutdown(); - if(Log.isInfo()) Log.i(TAG, "deleted"); + OMXVideoCodec videoCodec = videoCodec_; videoCodec_ = null; + if (videoCodec != null) { + videoCodec.shutdown(); + } + if(Log.isInfo()) Log.i(TAG, "deleted"); } } diff --git a/app/src/main/java/it/smg/hu/service/ODAService.java b/app/src/main/java/it/smg/hu/service/ODAService.java index e27fd410..fbeae502 100644 --- a/app/src/main/java/it/smg/hu/service/ODAService.java +++ b/app/src/main/java/it/smg/hu/service/ODAService.java @@ -10,11 +10,15 @@ import android.view.SurfaceView; import android.widget.Toast; +import java.util.concurrent.atomic.AtomicBoolean; + import androidx.annotation.Keep; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import it.smg.hu.R; import it.smg.hu.config.Settings; import it.smg.hu.manager.HondaConnectManager; +import it.smg.hu.manager.ConnectionManager; import it.smg.hu.manager.USBManager; import it.smg.hu.manager.WIFIManager; import it.smg.hu.projection.InputDevice; @@ -38,12 +42,18 @@ public class ODAService extends Service implements IAndroidAutoEntityEventHandle public static final String MODE_WIFI = "modeWifi"; private static final String TAG = "ODAService"; + private static final long SHUTDOWN_TIMEOUT_MS = 2000L; private final IBinder mBinder = new ServiceBinder(); private LocalBroadcastManager localBroadcastManager_; private NotificationFactory notificationFactory_; - private AndroidAutoEntity androidAutoEntity_; + /** + * Written by the connection thread and read by the UI thread and by native + * quit callbacks. It also gates whether a new session may start, so a stale + * read here would either skip a teardown or lock connecting out entirely. + */ + private volatile AndroidAutoEntity androidAutoEntity_; private USBManager usbManager_; private WIFIManager wifiManager_; @@ -52,7 +62,32 @@ public class ODAService extends Service implements IAndroidAutoEntityEventHandle private Handler mainHandler_; - private boolean isRunning_; + private volatile boolean isRunning_; + private volatile boolean stopRequested_; + private volatile boolean shutdownRequested_; + private String currentMode_; + + private final Runnable shutdownTimeout_ = new Runnable() { + @Override + public void run() { + if (shutdownRequested_) { + if (Log.isWarn()) Log.w(TAG, "graceful shutdown timed out; forcing teardown"); + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + stop(); + } + } + }; + + /** + * Guards the session lifecycle. Tearing an AndroidAutoEntity down runs a + * cascade of native destructors while io_service handlers are still in + * flight; letting a new session start on top of that left two entities alive + * at once, which showed up as SEGV in the asio strand and as a wedged main + * thread. A flag rather than a lock on purpose: stop() is reached both from + * the UI thread and from native callbacks, so blocking one on the other could + * deadlock the very service that has to finish the teardown. + */ + private final AtomicBoolean stopping_ = new AtomicBoolean(false); public ODAService() {} @@ -72,19 +107,30 @@ public void onCreate() { } public void startUsb(SurfaceView surfaceView, InputDevice.OnKeyHolder keyHolder){ + if (!canStartSession()) { + return; + } + currentMode_ = MODE_USB; + stopRequested_ = false; + ConnectionManager.instance().connecting(MODE_USB, getString(R.string.connection_usb_connecting)); startThread_ = new Thread(() -> { Looper.prepare(); if (usbManager_.aoapDevice() != null) { - isRunning_ = true; - if (Log.isVerbose()) Log.v(TAG, "aoap device available, start in usb mode"); try { LibUsbDevice device = usbManager_.aoapDevice(); if (device.open()) { if (Log.isInfo()) Log.i(TAG, "device opened"); androidAutoEntity_ = AndroidAutoEntityFactory.create(this, device, surfaceView, keyHolder); + if (stopRequested_) { + androidAutoEntity_.delete(); + androidAutoEntity_ = null; + return; + } androidAutoEntity_.start(this); + isRunning_ = true; + ConnectionManager.instance().active(getString(R.string.connection_usb_active)); } else { Log.e(TAG, "Error in open usb device"); onAndroidAutoQuitOnError("USB OPEN DEVICE", -1); @@ -96,6 +142,8 @@ public void startUsb(SurfaceView surfaceView, InputDevice.OnKeyHolder keyHolder) // onAndroidAutoQuit(); return; } + } else { + ConnectionManager.instance().failed(getString(R.string.connection_usb_unavailable)); } if (Log.isInfo()) Log.i(TAG, "start usb thead completed"); }); @@ -103,22 +151,42 @@ public void startUsb(SurfaceView surfaceView, InputDevice.OnKeyHolder keyHolder) } public void startWifi(SurfaceView surfaceView, InputDevice.OnKeyHolder keyHolder){ + if (!canStartSession()) { + return; + } + currentMode_ = MODE_WIFI; + stopRequested_ = false; + ConnectionManager.instance().connecting(MODE_WIFI, getString(R.string.connection_wifi_connecting)); startThread_ = new Thread(() -> { Looper.prepare(); + String ipAddress = wifiManager_.getIpAddress(); try { - String ipAddress = wifiManager_.getIpAddress(); if (ipAddress != null) { - isRunning_ = true; - // if (Log.isInfo()) Log.i(TAG, "Connect to ip " + ipAddress); TCPEndpoint tcpEndpoint = new TCPEndpoint(ipAddress); androidAutoEntity_ = AndroidAutoEntityFactory.create(this, tcpEndpoint, surfaceView, keyHolder); + if (stopRequested_) { + androidAutoEntity_.delete(); + androidAutoEntity_ = null; + return; + } androidAutoEntity_.start(this); + isRunning_ = true; + ConnectionManager.instance().active(getString(R.string.connection_wifi_active)); + } else { + ConnectionManager.instance().failed(getString(R.string.connection_wifi_gateway_error)); } if (Log.isInfo()) Log.i(TAG, "start wifi thead completed"); } catch (TCPConnectException e){ Log.e(TAG, "TCP Connection error", e); + // The phone refuses port 5277 unless Android Auto Wireless is + // actually running on it. Say so on screen: the projection window + // closes within a second either way, which otherwise looks + // exactly like the app crashing. + String reason = getString(R.string.connection_wifi_endpoint_error, ipAddress); + ConnectionManager.instance().failed(reason); + notifyUser(reason); stop(); } }); @@ -126,7 +194,26 @@ public void startWifi(SurfaceView surfaceView, InputDevice.OnKeyHolder keyHolder } public void shutdown(){ - androidAutoEntity_.shutdown(); + if (shutdownRequested_) { + return; + } + shutdownRequested_ = true; + AndroidAutoEntity entity = androidAutoEntity_; + if (entity == null) { + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + stop(); + return; + } + + mainHandler_.removeCallbacks(shutdownTimeout_); + mainHandler_.postDelayed(shutdownTimeout_, SHUTDOWN_TIMEOUT_MS); + try { + entity.shutdown(); + } catch (Throwable error) { + Log.e(TAG, "error requesting graceful Android Auto shutdown", error); + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + stop(); + } } public void releaseFocus(){ @@ -136,39 +223,103 @@ public void releaseFocus(){ } public void gainFocus(){ - androidAutoEntity_.gainFocus(); + if (androidAutoEntity_ != null) { + androidAutoEntity_.gainFocus(); + } + } + + /** + * Whether a new projection session may be started right now. A session that + * is still running, or one whose native teardown has not finished yet, must + * be left alone. + */ + private boolean canStartSession(){ + if (isRunning_ || androidAutoEntity_ != null || stopping_.get()) { + if (Log.isWarn()) Log.w(TAG, "start ignored: previous session still active or shutting down"); + return false; + } + return true; } public void stop(){ - if (!isRunning_) { - if (Log.isInfo()) Log.i(TAG, "service not running, already stopped?"); + stopRequested_ = true; + if (mainHandler_ != null) { + mainHandler_.removeCallbacks(shutdownTimeout_); + } + + // The native teardown is not reentrant, and stop() arrives from the UI + // thread, from the connection thread and from native quit callbacks. + if (!stopping_.compareAndSet(false, true)) { + if (Log.isInfo()) Log.i(TAG, "stop already in progress"); return; } - isRunning_ = false; + try { + if (!isRunning_ && androidAutoEntity_ == null) { + if (Log.isInfo()) Log.i(TAG, "service not running, already stopped?"); + localBroadcastManager_.sendBroadcast(new Intent(ODAService.STOP_ACTION)); + stopService(new Intent(this, ODAService.class)); + return; + } + + isRunning_ = false; + + if (Log.isInfo()) Log.i(TAG, "Stop"); + + // Every step below is individually guarded: anything that escapes here + // would leave androidAutoEntity_ set, and since that field gates + // canStartSession() the app could never connect again without being + // force stopped. + if (Settings.instance().advanced.hondaIntegrationEnabled()){ + // init() only runs at boot when the setting was already on, so + // enabling it later leaves the singleton null. + HondaConnectManager hondaManager = HondaConnectManager.instance(); + if (hondaManager != null) { + try { + hondaManager.endAudioBinding(); + } catch (Throwable t) { + Log.e(TAG, "error ending Honda audio binding", t); + } + } + } - if (Log.isInfo()) Log.i(TAG, "Stop"); + AndroidAutoEntity entity = androidAutoEntity_; + LibUsbDevice sessionUsbDevice = MODE_USB.equals(currentMode_) + ? usbManager_.aoapDevice() : null; + if (entity != null) { + try { + entity.stop(); + } catch (Throwable t) { + Log.e(TAG, "error stopping Android Auto entity", t); + } + if (sessionUsbDevice != null) { + usbManager_.resetSession(sessionUsbDevice); + } + try { + entity.delete(); + } catch (Throwable t) { + Log.e(TAG, "error deleting Android Auto entity", t); + } + } - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().endAudioBinding(); - } + if (sessionUsbDevice != null) { + usbManager_.finishSession(sessionUsbDevice); + } - if (androidAutoEntity_ != null) { - androidAutoEntity_.stop(); - } + Intent stopIntent = new Intent(ODAService.STOP_ACTION); + localBroadcastManager_.sendBroadcast(stopIntent); - if (androidAutoEntity_ != null) { - androidAutoEntity_.delete(); + Intent service = new Intent(this, ODAService.class); + stopService(service); + } finally { + // Cleared unconditionally so a failed teardown cannot wedge the + // service into a state where no further session can be started. androidAutoEntity_ = null; + startThread_ = null; + currentMode_ = null; + shutdownRequested_ = false; + stopping_.set(false); } - - startThread_ = null; - - Intent stopIntent = new Intent(ODAService.STOP_ACTION); - localBroadcastManager_.sendBroadcast(stopIntent); - - Intent service = new Intent(this, ODAService.class); - stopService(service); } @Override @@ -178,6 +329,13 @@ public IBinder onBind(Intent intent) { public void onDestroy() { if (Log.isDebug()) Log.d(TAG, "onDestroy"); + // stopService() from the home screen or the exit widget destroys this + // service without going through stop(), which used to leave the session, + // its io_service threads and the USB handle alive. The replacement service + // then starts with androidAutoEntity_ == null and happily builds a second + // session on top of the first one. stop() is idempotent via stopping_. + stop(); + super.onDestroy(); } @Override @@ -200,19 +358,53 @@ public int onStartCommand(Intent intent, int flags, int startId) { @Keep @Override public void onAndroidAutoQuit() { - stop(); + runNativeQuitOnMainThread(() -> { + if (shutdownRequested_) { + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + } else { + ConnectionManager.instance().detached(getString(R.string.connection_session_ended)); + } + stop(); + }); } @Keep @Override public void onAndroidAutoQuitOnError(String error, int nativeErrorCode){ - Log.e(TAG, "closing with error " + error + "(" + nativeErrorCode + ")"); + runNativeQuitOnMainThread(() -> { + if (shutdownRequested_) { + if (Log.isInfo()) Log.i(TAG, "transport closed while graceful shutdown was pending"); + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + stop(); + return; + } + Log.e(TAG, "closing with error " + error + "(" + nativeErrorCode + ")"); + String message = getString(R.string.connection_native_error_code, error, nativeErrorCode); + ConnectionManager.instance().failed(message); + notifyUser(message); - mainHandler_.post(() -> { - Toast.makeText(this, "Closed due to " + error + " error", Toast.LENGTH_LONG).show(); + stop(); }); + } - stop(); + /** + * Native errors are delivered from an aasdk io_service worker. Destroying the + * entity on that same worker leaves its currently executing asio handler + * running against a destroyed strand, producing the SEGV seen on disconnect. + * Queue the lifecycle work on Android's main thread so the worker can return + * before Runtime.stopIOServiceWorkers() joins it. + */ + private void runNativeQuitOnMainThread(Runnable action) { + if (mainHandler_ == null || Looper.myLooper() == Looper.getMainLooper()) { + action.run(); + } else { + mainHandler_.post(action); + } + } + + /** Shows a message to the user from any thread. */ + private void notifyUser(String message){ + mainHandler_.post(() -> Toast.makeText(this, message, Toast.LENGTH_LONG).show()); } @Keep diff --git a/app/src/main/java/it/smg/hu/ui/MainActivity.java b/app/src/main/java/it/smg/hu/ui/MainActivity.java index 64a79775..a5d6a75e 100644 --- a/app/src/main/java/it/smg/hu/ui/MainActivity.java +++ b/app/src/main/java/it/smg/hu/ui/MainActivity.java @@ -1,226 +1,170 @@ package it.smg.hu.ui; -import android.app.Activity; import android.content.BroadcastReceiver; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.ServiceConnection; -import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbManager; import android.net.ConnectivityManager; import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.widget.Toast; import androidx.fragment.app.FragmentActivity; import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import it.smg.hu.service.ODAService; import it.smg.hu.R; +import it.smg.hu.manager.ConnectionManager; +import it.smg.hu.manager.ConnectionState; import it.smg.hu.manager.USBManager; import it.smg.hu.manager.WIFIManager; +import it.smg.hu.service.ODAService; import it.smg.hu.ui.main.HomeFragment; -import it.smg.hu.ui.notification.AppBadge; -import it.smg.hu.ui.notification.NotificationFactory; -import it.smg.libs.common.Log; +/** Connection host. It launches the player only after a transport is ready. */ public class MainActivity extends FragmentActivity { - public static final int SETTINGS_ACTIVITY_REQUEST = 12345; - private final static String TAG = "MainActivity"; - private USBManager usbManager_; private WIFIManager wifiManager_; private LocalBroadcastManager localBroadcastManager_; - - private Intent newUsbDeviceIntent_ = null; - private boolean isOpenFromMain_ = false; - private boolean isOpenFromUsb_ = false; - private boolean isServiceBound_ = false; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (Log.isDebug()) Log.d(TAG, "onCreate, intent: " + getIntent()); - if (Log.isDebug()) Log.d(TAG, "onCreate, intent action: " + getIntent().getAction()); - - isOpenFromMain_ = false; - isOpenFromUsb_ = false; - - localBroadcastManager_ = LocalBroadcastManager.getInstance(this); - usbManager_ = USBManager.instance(); - wifiManager_ = WIFIManager.instance(); - - setContentView(R.layout.activity_main); - - Intent intent = getIntent(); - if (Log.isInfo()) Log.i(TAG, "intent action: " + intent.getAction()); - - if ( UsbManager.ACTION_USB_DEVICE_ATTACHED.equalsIgnoreCase(intent.getAction())) { - if (Log.isInfo()) Log.i(TAG, "new usb device intent: " + intent); - newUsbDeviceIntent_ = intent; - getWindow().setLayout(0, 0); - isOpenFromMain_ = false; - isOpenFromUsb_ = true; - } else if (Intent.ACTION_MAIN.equalsIgnoreCase(intent.getAction())) { - HomeFragment f = new HomeFragment(); - Bundle args = new Bundle(); - args.putBoolean(HomeFragment.AUTOSTART, true); - f.setArguments(args); - - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.home_content, f) - .commit(); - - isOpenFromMain_ = true; - isOpenFromUsb_ = false; - } - - } - - private void registerReceiver(){ - IntentFilter wifiFilter = new IntentFilter(); - wifiFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); - registerReceiver(wifiReceiver, wifiFilter); - - IntentFilter filter = new IntentFilter(); - filter.addAction(USBManager.ATTACH_AOAP_DEVICE); - localBroadcastManager_.registerReceiver(usbReceiver, filter); - } + private boolean receiversRegistered_; + private boolean playerLaunchPending_; private final BroadcastReceiver wifiReceiver = new BroadcastReceiver() { - private final static String TAG = "MainActivity-WifiReceiver"; @Override public void onReceive(Context context, Intent intent) { - if (Log.isDebug()) Log.d(TAG, "Received action: " + intent.getAction()); if (wifiManager_ != null) { wifiManager_.checkNetwork(); } } }; - private final BroadcastReceiver usbReceiver = new BroadcastReceiver() { + private final BroadcastReceiver usbAttachReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - final String TAG = "MainActivity-UsbReceiver"; - - if (Log.isDebug()) Log.d(TAG, intent.getAction()); - if (isOpenFromUsb_) { - if (intent.getAction().equals(USBManager.ATTACH_AOAP_DEVICE)) { - if (Log.isDebug()) Log.d(TAG, "open popup activity"); - NotificationFactory.instance().notifyStartRequest(); + if (usbManager_ == null || intent == null + || !UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { + return; + } + usbManager_.onUsbDeviceDetected(intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)); + } + }; -// MainActivity.this.finish(); + private final BroadcastReceiver connectionReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (ConnectionManager.ACTION_STATE_CHANGED.equals(action)) { + String state = intent.getStringExtra(ConnectionManager.EXTRA_STATE); + if (ConnectionState.ERROR.name().equals(state) || ConnectionState.IDLE.name().equals(state) + || ConnectionState.EXITED.name().equals(state)) { + playerLaunchPending_ = false; } } } }; @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - if (Log.isDebug()) Log.d(TAG, "onNewIntent, action " + intent.getAction()); - if ( UsbManager.ACTION_USB_DEVICE_ATTACHED.equalsIgnoreCase(intent.getAction())) { - if (Log.isInfo()) Log.i(TAG, "attached new device, keep intent"); - newUsbDeviceIntent_ = intent; + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + usbManager_ = USBManager.instance(); + wifiManager_ = WIFIManager.instance(); + localBroadcastManager_ = LocalBroadcastManager.getInstance(this); + + setContentView(R.layout.activity_main); + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.home_content, new HomeFragment()) + .commit(); } } @Override protected void onResume() { super.onResume(); - if (Log.isDebug()) Log.d(TAG, "onResume"); - - Intent odaServiceIntent = new Intent(this, ODAService.class); - startService(odaServiceIntent); - - registerReceiver(); - - Intent intent = newUsbDeviceIntent_; - if (Log.isDebug()) Log.d(TAG, "onResume intent: " + intent); - if (intent != null) { - if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equalsIgnoreCase(intent.getAction())) { - UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - if (Log.isDebug()) Log.d(TAG, "attached device " + device); - if (device != null) { - usbManager_.checkDevice(device); - } - } else if (USBManager.ACTION_USB_PERMISSION.equalsIgnoreCase(intent.getAction())) { - UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - if (Log.isDebug()) Log.d(TAG, "permission granted for device " + device); - if (device != null) { - usbManager_.checkDevice(device); - } - } else { - if (Log.isWarn()) Log.w(TAG, "permission negated for device " + device + " do nothing"); - } - } + playerLaunchPending_ = PlayerActivity.isActive(); + registerConnectionReceivers(); + if (usbManager_ != null) { + usbManager_.rescanAttachedDevices(); + } + if (wifiManager_ != null) { + wifiManager_.checkNetwork(); } - -// NotificationFactory.init(getApplicationContext()); -// NotificationFactory.instance().notifyStartRequest(); - AppBadge.instance().dismiss(); - - newUsbDeviceIntent_ = null; } @Override protected void onPause() { + unregisterConnectionReceivers(); super.onPause(); - unregisterReceiver(wifiReceiver); - - localBroadcastManager_.unregisterReceiver(usbReceiver); - if (Log.isDebug()) Log.d(TAG, "onPause"); } - @Override - protected void onStart() { - if (Log.isDebug()) Log.d(TAG, "onStart"); - super.onStart(); + private void registerConnectionReceivers() { + if (receiversRegistered_) { + return; + } + registerReceiver(wifiReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + registerReceiver(usbAttachReceiver, new IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED)); + IntentFilter localFilter = new IntentFilter(); + localFilter.addAction(USBManager.ATTACH_AOAP_DEVICE); + localFilter.addAction(WIFIManager.CONNECT_WIFI); + localFilter.addAction(ConnectionManager.ACTION_STATE_CHANGED); + localBroadcastManager_.registerReceiver(connectionReceiver, localFilter); + receiversRegistered_ = true; } - @Override - protected void onStop() { - if (Log.isDebug()) Log.d(TAG, "onStop"); - super.onStop(); + private void unregisterConnectionReceivers() { + if (!receiversRegistered_) { + return; + } + unregisterReceiver(wifiReceiver); + unregisterReceiver(usbAttachReceiver); + localBroadcastManager_.unregisterReceiver(connectionReceiver); + receiversRegistered_ = false; } - @Override - protected void onDestroy() { - super.onDestroy(); - if (Log.isDebug()) Log.d(TAG, "onDestroy"); + public void startConnectionManually(String mode) { + if (mode == null || playerLaunchPending_ || PlayerActivity.isActive()) { + return; + } + if (!ConnectionManager.instance().isManualStartAllowed()) { + return; + } + if (ODAService.MODE_WIFI.equals(mode) && usbManager_ != null && usbManager_.aoapDevice() != null) { + return; + } + playerLaunchPending_ = true; + Intent player = new Intent(this, PlayerActivity.class); + player.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + player.putExtra("mode", mode); + startActivity(player); } - private boolean doubleBackToExitPressedOnce = false; - - public void exitApp(){ - Intent service = new Intent(this, ODAService.class); - stopService(service); - - Log.shutdown(); + /** Called by the home screen and PlayerActivity to prevent a reconnect loop. */ + public void exitSession() { + playerLaunchPending_ = false; + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + stopService(new Intent(this, ODAService.class)); + } + /** Closes the task and terminates the process after service cleanup has run. */ + public void exitApplication() { + exitSession(); finish(); -// android.os.Process.killProcess(android.os.Process.myPid()); - return; + + Thread terminator = new Thread(() -> { + try { + Thread.sleep(750L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(0); + }, "ODA-app-exit"); + terminator.start(); } @Override public void onBackPressed() { - if (doubleBackToExitPressedOnce) { - super.onBackPressed(); - exitApp(); - return; - } - - this.doubleBackToExitPressedOnce = true; - Toast.makeText(this, "Please click BACK again to exit", Toast.LENGTH_SHORT).show(); - - new Handler().postDelayed(() -> doubleBackToExitPressedOnce=false, 2000); + exitSession(); } -} \ No newline at end of file + +} diff --git a/app/src/main/java/it/smg/hu/ui/PlayerActivity.java b/app/src/main/java/it/smg/hu/ui/PlayerActivity.java index f71d3e4a..ffb9de2f 100644 --- a/app/src/main/java/it/smg/hu/ui/PlayerActivity.java +++ b/app/src/main/java/it/smg/hu/ui/PlayerActivity.java @@ -21,6 +21,7 @@ import it.smg.hu.R; import it.smg.hu.config.Settings; import it.smg.hu.manager.HondaConnectManager; +import it.smg.hu.manager.ConnectionManager; import it.smg.hu.projection.InputDevice; import it.smg.hu.service.ODAService; import it.smg.hu.ui.notification.AppBadge; @@ -56,7 +57,7 @@ protected void onCreate(Bundle savedInstanceState) { isRunning_ = savedInstanceState.getBoolean("isRunning"); } else { Bundle b = getIntent().getExtras(); - startMode_ = b.getString("mode"); + startMode_ = b == null ? null : b.getString("mode"); } if (Log.isDebug()) Log.d(TAG, "start mode: " + startMode_); @@ -66,8 +67,9 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_player); - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().initialize(); + HondaConnectManager hondaManager = hondaManager(); + if (hondaManager != null){ + hondaManager.initialize(); } surfaceView_ = findViewById(R.id.surfaceView); @@ -92,12 +94,7 @@ protected void onSaveInstanceState(Bundle outState) { @Override public void onBackPressed() { if (Log.isDebug()) Log.d(TAG, "onBackPressed"); - - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().endAudioBinding(); - } - - odaService_.stop(); + exitSession(); } @Override @@ -105,11 +102,7 @@ public boolean onKeyLongPress(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { if (Log.isInfo()) Log.i(TAG, "Back button long pressed"); - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().endAudioBinding(); - } - - odaService_.stop(); + exitSession(); return true; } return super.onKeyLongPress(keyCode, event); @@ -122,14 +115,25 @@ protected void onResume() { Intent odaServiceIntent = new Intent(this, ODAService.class); startService(odaServiceIntent); - bindService(odaServiceIntent, this, BIND_AUTO_CREATE | BIND_ABOVE_CLIENT | BIND_IMPORTANT); + // Track the binding from the moment it is accepted, not from + // onServiceConnected: an onPause arriving before the connection lands + // would otherwise skip unbindService and leak this ServiceConnection, + // and the next onResume would bind the same object a second time. + if (bindService(odaServiceIntent, this, BIND_AUTO_CREATE | BIND_ABOVE_CLIENT | BIND_IMPORTANT)) { + isServiceBound_ = true; + } - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().initAudioBinding(); + HondaConnectManager hondaManager = hondaManager(); + if (hondaManager != null){ + hondaManager.initAudioBinding(); } - NotificationFactory.instance().dismissAll(); - AppBadge.instance().dismiss(); + if (NotificationFactory.instance() != null) { + NotificationFactory.instance().dismissAll(); + } + if (AppBadge.instance() != null) { + AppBadge.instance().dismiss(); + } initReceivers(); isActive_ = true; @@ -139,19 +143,35 @@ protected void onResume() { protected void onPause() { if (Log.isDebug()) Log.d(TAG, "onPause"); super.onPause(); - odaService_.releaseFocus(); + if (odaService_ != null) { + odaService_.releaseFocus(); + } if (isServiceBound_) { - unbindService(this); + isServiceBound_ = false; + try { + unbindService(this); + } catch (IllegalArgumentException e) { + Log.e(TAG, "service was not bound", e); + } } - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().sendToBackground(); + HondaConnectManager hondaManager = hondaManager(); + if (hondaManager != null){ + hondaManager.sendToBackground(); } - AppBadge.instance().show(); + // The projection exit must always return to the dashboard cleanly. A + // floating badge here can reopen a half-torn-down PlayerActivity and + // create a second Android Auto session. + if (AppBadge.instance() != null) { + AppBadge.instance().dismiss(); + } - localBroadcastManager_.unregisterReceiver(localReceiver_); + if (localReceiver_ != null) { + localBroadcastManager_.unregisterReceiver(localReceiver_); + localReceiver_ = null; + } isActive_ = false; } @@ -207,7 +227,9 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig @Override public void surfaceDestroyed(SurfaceHolder holder) { Log.d(TAG, "surfaceDestroyed"); - odaService_.releaseFocus(); + if (odaService_ != null) { + odaService_.releaseFocus(); + } } @Override @@ -225,10 +247,29 @@ public void onServiceConnected(ComponentName name, IBinder service) { } } + /** + * The Honda glue, or null when it is switched off or was never initialised. + * HondaConnectManager.init() only runs at boot when the setting was already + * enabled, so turning it on later leaves instance() null. + */ + private HondaConnectManager hondaManager(){ + if (!Settings.instance().advanced.hondaIntegrationEnabled()){ + return null; + } + return HondaConnectManager.instance(); + } + private void start(){ - if (Settings.instance().advanced.hondaIntegrationEnabled()){ - HondaConnectManager.instance().adjustPermission(); + HondaConnectManager hondaManager = hondaManager(); + if (hondaManager != null){ + hondaManager.adjustPermission(); + } + + if (startMode_ == null) { + ConnectionManager.instance().failed(getString(R.string.connection_mode_missing)); + finish(); + return; } switch (startMode_){ @@ -250,7 +291,9 @@ private void start(){ public void onServiceDisconnected(ComponentName name) { if (Log.isDebug()) Log.d(TAG, "onServiceDisconnected"); odaService_ = null; - isServiceBound_ = false; + // isServiceBound_ stays true on purpose: the service process died but the + // binding is still registered, so onPause must still unbind it. Clearing + // the flag here leaked the ServiceConnection on every service crash. } @Override @@ -276,4 +319,18 @@ public void onReceive(Context context, Intent intent) { public static boolean isActive(){ return isActive_; } + + private void exitSession() { + if (AppBadge.instance() != null) { + AppBadge.instance().dismiss(); + } + if (odaService_ != null) { + ConnectionManager.instance().disconnecting(getString(R.string.connection_user_stopping)); + odaService_.shutdown(); + finish(); + } else { + ConnectionManager.instance().userExited(getString(R.string.connection_user_stopped)); + finish(); + } + } } diff --git a/app/src/main/java/it/smg/hu/ui/SettingsActivity.java b/app/src/main/java/it/smg/hu/ui/SettingsActivity.java index 3261b496..bd68c9ef 100644 --- a/app/src/main/java/it/smg/hu/ui/SettingsActivity.java +++ b/app/src/main/java/it/smg/hu/ui/SettingsActivity.java @@ -5,8 +5,11 @@ import android.os.Bundle; import android.view.KeyEvent; import android.view.View; +import android.widget.Button; import android.widget.ImageButton; +import android.widget.TextView; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import it.smg.hu.R; @@ -20,86 +23,123 @@ import it.smg.libs.common.Log; public class SettingsActivity extends FragmentActivity implements InputDevice.OnKeyHolder { - private static final String TAG = "SettingsActivity"; + private static final String SELECTED_NAVIGATION_ID = "selectedNavigationId"; + private static final int[] NAVIGATION_IDS = { + R.id.car_settings, R.id.video_settings, R.id.conn_settings, + R.id.keymap_settings, R.id.advanced_settings + }; private View.OnKeyListener keyListener_; - private Settings settings_; + private int selectedNavigationId_ = R.id.car_settings; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); - settings_ = Settings.instance(); + findViewById(R.id.settingsBackBtn).setOnClickListener(v -> onBackPressed()); + findViewById(R.id.settingsThemeBtn).setOnClickListener(v -> { + v.setEnabled(false); + Settings settings = Settings.instance(); + settings.appearance.darkTheme(!settings.appearance.darkTheme()); + applyTheme(); + showSettings(fragmentForNavigation(selectedNavigationId_), + titleForNavigation(selectedNavigationId_), selectedNavigationId_); + v.postDelayed(() -> v.setEnabled(true), 350); + }); + findViewById(R.id.car_settings).setOnClickListener(v -> showSettings(new CarFragment(), R.string.car_settings, R.id.car_settings)); + findViewById(R.id.video_settings).setOnClickListener(v -> showSettings(new VideoFragment(), R.string.video_settings, R.id.video_settings)); + findViewById(R.id.conn_settings).setOnClickListener(v -> showSettings(new ConnectivityFragment(), R.string.conn_settings, R.id.conn_settings)); + findViewById(R.id.keymap_settings).setOnClickListener(v -> showSettings(new KeymapFragment(), R.string.keymap_settings, R.id.keymap_settings)); + findViewById(R.id.advanced_settings).setOnClickListener(v -> showSettings(new AdvancedFragment(), R.string.advanced_settings, R.id.advanced_settings)); + + if (savedInstanceState != null) { + selectedNavigationId_ = savedInstanceState.getInt(SELECTED_NAVIGATION_ID, R.id.car_settings); + } + applyTheme(); + if (savedInstanceState == null) { + showSettings(new CarFragment(), R.string.car_settings, R.id.car_settings); + } else { + ((TextView) findViewById(R.id.settingsTitle)).setText(titleForNavigation(selectedNavigationId_)); + renderNavigation(); + } + } - setContentView(R.layout.activity_settings); + private void showSettings(Fragment fragment, int titleResource, int selectedNavigationId) { + selectedNavigationId_ = selectedNavigationId; + ((TextView) findViewById(R.id.settingsTitle)).setText(titleResource); + renderNavigation(); + findViewById(R.id.settingsScroll).scrollTo(0, 0); + getSupportFragmentManager().beginTransaction() + .replace(R.id.main_content, fragment) + .commit(); + } - ImageButton carImage = findViewById(R.id.car_settings); - carImage.setOnClickListener( - listener -> getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_content, new CarFragment()) - .commit() - ); - - ImageButton advancedImage = findViewById(R.id.advanced_settings); - advancedImage.setOnClickListener( - listener -> getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_content, new AdvancedFragment()) - .commit() - ); - - ImageButton videoImage = findViewById(R.id.video_settings); - videoImage.setOnClickListener( - listener -> getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_content, new VideoFragment()) - .commit() - ); - - ImageButton keymapImage = findViewById(R.id.keymap_settings); - keymapImage.setOnClickListener( - listener -> getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_content, new KeymapFragment()) - .commit() - ); - -// audioImage = findViewById(R.id.audio_settings); -// audioImage.setOnClickListener( -// listener -> getSupportFragmentManager() -// .beginTransaction() -// .replace(R.id.main_content, new AudioFragment()) -// .commit() -// ); - - ImageButton connImage = findViewById(R.id.conn_settings); - connImage.setOnClickListener( - listener -> getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_content, new ConnectivityFragment()) - .commit() - ); - - getSupportFragmentManager().beginTransaction().replace(R.id.main_content, new CarFragment()).commit(); + private void applyTheme() { + boolean dark = Settings.instance().appearance.darkTheme(); + int surfaceColor = dark ? R.color.oda_surface : R.color.settings_surface; + int elevatedColor = dark ? R.color.oda_surface_elevated : R.color.home_light_surface_elevated; + int primaryColor = dark ? R.color.oda_text_primary : R.color.settings_text_primary; + int secondaryColor = dark ? R.color.oda_text_secondary : R.color.settings_text_secondary; + int secondaryButton = dark ? R.drawable.button_secondary : R.drawable.button_secondary_light; + + findViewById(R.id.settingsRoot).setBackgroundColor(getResources().getColor(surfaceColor)); + findViewById(R.id.settingsHeader).setBackgroundColor(getResources().getColor(elevatedColor)); + findViewById(R.id.settingsScroll).setBackgroundColor(getResources().getColor(surfaceColor)); + findViewById(R.id.main_content).setBackgroundColor(getResources().getColor(surfaceColor)); + findViewById(R.id.settingsNav).setBackgroundColor(getResources().getColor(elevatedColor)); + ((TextView) findViewById(R.id.settingsTitle)).setTextColor(getResources().getColor(primaryColor)); + ((TextView) findViewById(R.id.settingsSubtitle)).setTextColor(getResources().getColor(secondaryColor)); + + Button backButton = findViewById(R.id.settingsBackBtn); + backButton.setBackgroundResource(secondaryButton); + backButton.setTextColor(getResources().getColor(primaryColor)); + Button themeButton = findViewById(R.id.settingsThemeBtn); + themeButton.setBackgroundResource(secondaryButton); + themeButton.setTextColor(getResources().getColor(primaryColor)); + themeButton.setText(dark ? R.string.theme_dark : R.string.theme_light); + themeButton.setSelected(dark); + renderNavigation(); } - @Override - protected void onResume() { - super.onResume(); - if (Log.isDebug()) Log.d(TAG, "onResume"); + private void renderNavigation() { + boolean dark = Settings.instance().appearance.darkTheme(); + int unselectedBackground = dark ? R.drawable.button_secondary : R.drawable.button_secondary_light; + int unselectedTint = dark ? R.color.oda_text_primary : R.color.settings_text_primary; + for (int id : NAVIGATION_IDS) { + boolean selected = id == selectedNavigationId_; + ImageButton button = findViewById(id); + button.setBackgroundResource(selected ? R.drawable.button_primary : unselectedBackground); + button.setColorFilter(getResources().getColor(selected ? R.color.oda_on_accent : unselectedTint)); + button.setSelected(selected); + } + } + + private int titleForNavigation(int navigationId) { + if (navigationId == R.id.video_settings) return R.string.video_settings; + if (navigationId == R.id.conn_settings) return R.string.conn_settings; + if (navigationId == R.id.keymap_settings) return R.string.keymap_settings; + if (navigationId == R.id.advanced_settings) return R.string.advanced_settings; + return R.string.car_settings; + } + + private Fragment fragmentForNavigation(int navigationId) { + if (navigationId == R.id.video_settings) return new VideoFragment(); + if (navigationId == R.id.conn_settings) return new ConnectivityFragment(); + if (navigationId == R.id.keymap_settings) return new KeymapFragment(); + if (navigationId == R.id.advanced_settings) return new AdvancedFragment(); + return new CarFragment(); } @Override - protected void onPause() { - super.onPause(); - if (Log.isDebug()) Log.d(TAG, "onPause"); + protected void onSaveInstanceState(Bundle outState) { + outState.putInt(SELECTED_NAVIGATION_ID, selectedNavigationId_); + super.onSaveInstanceState(outState); } @Override public void onBackPressed() { - Intent i = new Intent(); - setResult(Activity.RESULT_OK, i); + setResult(Activity.RESULT_OK, new Intent()); super.onBackPressed(); } @@ -113,5 +153,4 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { public void setOnKeyListener(View.OnKeyListener listener) { keyListener_ = listener; } - } diff --git a/app/src/main/java/it/smg/hu/ui/main/HomeFragment.java b/app/src/main/java/it/smg/hu/ui/main/HomeFragment.java index 0589ac6e..40bfd3e7 100644 --- a/app/src/main/java/it/smg/hu/ui/main/HomeFragment.java +++ b/app/src/main/java/it/smg/hu/ui/main/HomeFragment.java @@ -1,12 +1,9 @@ package it.smg.hu.ui.main; -import android.app.AlertDialog; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbManager; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -20,208 +17,275 @@ import it.smg.hu.R; import it.smg.hu.config.Settings; +import it.smg.hu.manager.ConnectionManager; +import it.smg.hu.manager.ConnectionState; import it.smg.hu.manager.USBManager; import it.smg.hu.manager.WIFIManager; import it.smg.hu.service.ODAService; import it.smg.hu.ui.MainActivity; -import it.smg.hu.ui.PlayerActivity; import it.smg.hu.ui.SettingsActivity; -import it.smg.libs.aasdk.usb.LibUsbDevice; -import it.smg.libs.common.Log; +/** The stationary connection dashboard. Starting a session always remains a deliberate action. */ public class HomeFragment extends Fragment { - - private static final String TAG = "HomeFragment"; - - public static final String AUTOSTART = "enableAutostart"; - - private ImageView usbIconConnection_; - private TextView txtUsbIconConnection_; - private ImageView wifiIconConnection_; - private TextView txtWifiIconConnection_; - private Button startBtn_; - private Button startUsbBtn_; - - private BroadcastReceiver localReceiver_; - private LocalBroadcastManager localBroadcastManager_; - + private View root_; + private View statusPanel_; + private TextView homeTitle_; + private TextView homeSubtitle_; + private TextView statusBadge_; + private TextView statusTitle_; + private TextView statusDetail_; + private TextView usbStatus_; + private TextView wifiStatus_; + private ImageView indicator_; + private Button retryButton_; + private Button settingsButton_; + private Button themeButton_; + private Button exitButton_; private USBManager usbManager_; - private WIFIManager WIFIManager_; + private WIFIManager wifiManager_; + private LocalBroadcastManager broadcasts_; + private ConnectionState renderedState_; + private boolean darkTheme_; - private Settings settings_; + private final BroadcastReceiver receiver_ = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (ConnectionManager.ACTION_STATE_CHANGED.equals(action)) { + String name = intent.getStringExtra(ConnectionManager.EXTRA_STATE); + ConnectionState state = name == null ? ConnectionState.IDLE : ConnectionState.valueOf(name); + renderState(state, intent.getStringExtra(ConnectionManager.EXTRA_MESSAGE), + intent.getStringExtra(ConnectionManager.EXTRA_MODE)); + } else if (USBManager.ATTACH_AOAP_DEVICE.equals(action)) { + usbStatus_.setText(R.string.home_usb_ready); + } else if (USBManager.DETACH_AOAP_DEVICE.equals(action)) { + usbStatus_.setText(R.string.home_usb_waiting); + } else if (WIFIManager.CONNECT_WIFI.equals(action)) { + String ssid = intent.getStringExtra(WIFIManager.EXTRA_SSID); + wifiStatus_.setText(getString(R.string.home_wifi_ready, ssid == null ? "" : ssid)); + } else if (WIFIManager.DISCONNECT_WIFI.equals(action)) { + renderWifiAvailability(); + } + } + }; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (Log.isDebug()) Log.d(TAG, "onCreateView"); - - super.onCreateView(inflater, container, savedInstanceState); View view = inflater.inflate(R.layout.home_fragment, container, false); - - settings_ = Settings.instance(); - localBroadcastManager_ = LocalBroadcastManager.getInstance(getContext()); - usbManager_ = USBManager.instance(); - WIFIManager_ = WIFIManager.instance(); - - if (usbManager_.aoapDevice() == null){ - usbManager_.searchForAoapDevice(); - } - - initUI(view); - + wifiManager_ = WIFIManager.instance(); + broadcasts_ = LocalBroadcastManager.getInstance(requireContext()); + + root_ = view.findViewById(R.id.homeRoot); + statusPanel_ = view.findViewById(R.id.statusPanel); + homeTitle_ = view.findViewById(R.id.homeTitle); + homeSubtitle_ = view.findViewById(R.id.homeSubtitle); + statusBadge_ = view.findViewById(R.id.statusBadge); + statusTitle_ = view.findViewById(R.id.statusTitle); + statusDetail_ = view.findViewById(R.id.statusDetail); + usbStatus_ = view.findViewById(R.id.usbStatus); + wifiStatus_ = view.findViewById(R.id.wifiStatus); + indicator_ = view.findViewById(R.id.connectionIndicator); + retryButton_ = view.findViewById(R.id.retryBtn); + settingsButton_ = view.findViewById(R.id.settingsBtn); + themeButton_ = view.findViewById(R.id.themeBtn); + exitButton_ = view.findViewById(R.id.exitBtn); + + settingsButton_.setOnClickListener(v -> + startActivityForResult(new Intent(getContext(), SettingsActivity.class), MainActivity.SETTINGS_ACTIVITY_REQUEST)); + themeButton_.setOnClickListener(v -> toggleTheme()); + exitButton_.setOnClickListener(v -> ((MainActivity) requireActivity()).exitApplication()); + retryButton_.setOnClickListener(v -> retryConnection()); + + applyTheme(); + renderWifiAvailability(); + renderState(ConnectionManager.instance().state(), ConnectionManager.instance().lastMessage(), + ConnectionManager.instance().mode()); return view; } - private void initUI(View view){ - usbIconConnection_ = view.findViewById(R.id.usbIcon); - usbIconConnection_.setImageResource(R.drawable.usb_dis); - txtUsbIconConnection_ = view.findViewById(R.id.txtUsbIcon); - txtUsbIconConnection_.setText(""); - - Button settingsBtn = view.findViewById(R.id.settingsBtn); - - wifiIconConnection_ = view.findViewById(R.id.wifiIcon); - wifiIconConnection_.setImageResource(R.drawable.wifi_dis); - txtWifiIconConnection_ = view.findViewById(R.id.txtWifiIcon); - txtWifiIconConnection_.setText(""); - startBtn_ = view.findViewById(R.id.startBtn); - startBtn_.setEnabled(false); - startBtn_.setAlpha(0.5f); - - if (settings_.advanced.enableWiFi()) { - startBtn_.setVisibility(View.VISIBLE); - wifiIconConnection_.setVisibility(View.VISIBLE); - txtWifiIconConnection_.setVisibility(View.VISIBLE); - } else { - startBtn_.setVisibility(View.GONE); - wifiIconConnection_.setVisibility(View.GONE); - txtWifiIconConnection_.setVisibility(View.GONE); - } - - startUsbBtn_ = view.findViewById(R.id.startUsbBtn); - startUsbBtn_.setEnabled(false); - startUsbBtn_.setAlpha(0.5f); - Button exit = view.findViewById(R.id.exitBtn); - - settingsBtn.setOnClickListener(c -> { - Intent i = new Intent(getContext(), SettingsActivity.class); - startActivityForResult(i, MainActivity.SETTINGS_ACTIVITY_REQUEST); - }); - - startBtn_.setOnClickListener(c -> { - if (Log.isInfo()) Log.i(TAG, "Starting in WiFi mode"); - start(ODAService.MODE_WIFI); - }); - - startUsbBtn_.setOnClickListener(c -> { - if (Log.isInfo()) Log.i(TAG, "Starting in USB mode"); - start(ODAService.MODE_USB); - }); - - exit.setOnClickListener(c -> { - // TODO: fix - ((MainActivity) getActivity()).exitApp(); - }); - - usbIconConnection_.setOnClickListener(v -> { - AlertDialog dialog = new DeviceListDialog(getActivity()); - dialog.show(); - }); - - initUsbDeviceIcon(); - } - - private void initUsbDeviceIcon(){ - if (usbManager_.aoapDevice() != null){ - LibUsbDevice device = usbManager_.aoapDevice(); - if (Log.isDebug()) Log.d(TAG, "current aoap device " + device); - usbIconConnection_.setImageResource(R.drawable.usb); - txtUsbIconConnection_.setText(device.product); - startUsbBtn_.setEnabled(true); - startUsbBtn_.setAlpha(1f); - } + @Override + public void onStart() { + super.onStart(); + IntentFilter filter = new IntentFilter(); + filter.addAction(ConnectionManager.ACTION_STATE_CHANGED); + filter.addAction(USBManager.ATTACH_AOAP_DEVICE); + filter.addAction(USBManager.DETACH_AOAP_DEVICE); + filter.addAction(WIFIManager.CONNECT_WIFI); + filter.addAction(WIFIManager.DISCONNECT_WIFI); + broadcasts_.registerReceiver(receiver_, filter); + // State changes can happen while PlayerActivity covers this fragment. + // Render the current snapshot on return so DISCONNECTING and its + // completion are visible even if their broadcasts were missed. + renderWifiAvailability(); + renderState(ConnectionManager.instance().state(), ConnectionManager.instance().lastMessage(), + ConnectionManager.instance().mode()); } - public void start(String mode){ - Intent i = new Intent(getContext(), PlayerActivity.class); - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - i.putExtra("mode", mode); - startActivity(i); + @Override + public void onStop() { + broadcasts_.unregisterReceiver(receiver_); + super.onStop(); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (Log.isDebug()) Log.d(TAG, "onActivityResult " + resultCode); - if (requestCode == MainActivity.SETTINGS_ACTIVITY_REQUEST){ - - if (settings_.advanced.enableWiFi()) { - startBtn_.setVisibility(View.VISIBLE); - wifiIconConnection_.setVisibility(View.VISIBLE); - txtWifiIconConnection_.setVisibility(View.VISIBLE); - } else { - startBtn_.setVisibility(View.GONE); - wifiIconConnection_.setVisibility(View.GONE); - txtWifiIconConnection_.setVisibility(View.GONE); + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == MainActivity.SETTINGS_ACTIVITY_REQUEST) { + applyTheme(); + renderWifiAvailability(); + if (wifiManager_ != null) { + wifiManager_.checkNetwork(); } + } + } - initUsbDeviceIcon(); + private void retryConnection() { + String mode = ConnectionManager.instance().mode(); + if (ODAService.MODE_USB.equals(mode) && usbManager_ != null + && (renderedState_ == ConnectionState.ERROR || usbManager_.aoapDevice() == null)) { + // Recovery only prepares a fresh USB transport. Starting a new + // PlayerActivity before that preparation completes races the prior + // TLS teardown and produces SSL_READ(5). + usbManager_.recoverConnection(); + } else if (renderedState_ == ConnectionState.ERROR && mode != null) { + // Move a Wi-Fi retry back to a startable state explicitly. ERROR is + // otherwise blocked by ConnectionPolicy to prevent accidental + // overlapping sessions from widgets or duplicate taps. + ConnectionManager.instance().transportAvailable(mode, + ConnectionManager.instance().lastMessage()); + ((MainActivity) requireActivity()).startConnectionManually(mode); + } else if (mode != null && (!ODAService.MODE_USB.equals(mode) + || (usbManager_ != null && usbManager_.aoapDevice() != null))) { + ((MainActivity) requireActivity()).startConnectionManually(mode); } - super.onActivityResult(requestCode, resultCode, data); } - @Override - public void onStart() { - super.onStart(); - if (Log.isDebug()) Log.d(TAG, "onStart"); - - if (Log.isDebug()) Log.d(TAG, "Registering LocalReceiver"); - IntentFilter localFilter = new IntentFilter(); - localFilter.addAction(USBManager.ATTACH_AOAP_DEVICE); - localFilter.addAction(USBManager.DETACH_AOAP_DEVICE); - localFilter.addAction(WIFIManager.CONNECT_WIFI); - localFilter.addAction(WIFIManager.DISCONNECT_WIFI); - localReceiver_ = new LocalReceiver(); - LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(getActivity()); - localBroadcastManager.registerReceiver(localReceiver_, localFilter); + private void renderWifiAvailability() { + if (Settings.instance().advanced.enableWiFi()) { + wifiStatus_.setText(R.string.home_wifi_waiting); + } else { + wifiStatus_.setText(R.string.home_wifi_off); + } } - @Override - public void onStop() { - super.onStop(); - if (Log.isDebug()) Log.d(TAG, "onStop"); - LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(localReceiver_); + private void toggleTheme() { + Settings.instance().appearance.darkTheme(!Settings.instance().appearance.darkTheme()); + applyTheme(); + renderState(ConnectionManager.instance().state(), ConnectionManager.instance().lastMessage(), + ConnectionManager.instance().mode()); } - private class LocalReceiver extends BroadcastReceiver { - private final static String TAG = "HomeFragment-LocalReceiver"; + private void applyTheme() { + darkTheme_ = Settings.instance().appearance.darkTheme(); + int surfaceColor = darkTheme_ ? R.color.oda_surface : R.color.home_light_surface; + int primaryColor = darkTheme_ ? R.color.oda_text_primary : R.color.home_light_text_primary; + int secondaryColor = darkTheme_ ? R.color.oda_text_secondary : R.color.home_light_text_secondary; + int secondaryButton = darkTheme_ ? R.drawable.button_secondary : R.drawable.button_secondary_light; + + root_.setBackgroundColor(getResources().getColor(surfaceColor)); + statusPanel_.setBackgroundResource(darkTheme_ ? R.drawable.panel_surface : R.drawable.panel_surface_light); + homeTitle_.setTextColor(getResources().getColor(primaryColor)); + homeSubtitle_.setTextColor(getResources().getColor(secondaryColor)); + statusTitle_.setTextColor(getResources().getColor(primaryColor)); + statusDetail_.setTextColor(getResources().getColor(secondaryColor)); + usbStatus_.setTextColor(getResources().getColor(secondaryColor)); + wifiStatus_.setTextColor(getResources().getColor(secondaryColor)); + usbStatus_.setBackgroundResource(darkTheme_ ? R.drawable.status_badge_idle : R.drawable.status_badge_idle_light); + wifiStatus_.setBackgroundResource(darkTheme_ ? R.drawable.status_badge_idle : R.drawable.status_badge_idle_light); + + styleSecondaryButton(settingsButton_, secondaryButton, primaryColor); + styleSecondaryButton(themeButton_, secondaryButton, primaryColor); + styleSecondaryButton(exitButton_, secondaryButton, primaryColor); + themeButton_.setText(darkTheme_ ? R.string.theme_dark : R.string.theme_light); + themeButton_.setSelected(darkTheme_); + } - @Override - public void onReceive(Context context, Intent intent) { - if (Log.isDebug()) Log.d(TAG, "received action " + intent.getAction()); - if (USBManager.DETACH_AOAP_DEVICE.equalsIgnoreCase(intent.getAction())){ - usbIconConnection_.setImageResource(R.drawable.usb_dis); - txtUsbIconConnection_.setText(""); - startUsbBtn_.setEnabled(false); - startUsbBtn_.setAlpha(0.5f); - } else if (USBManager.ATTACH_AOAP_DEVICE.equalsIgnoreCase(intent.getAction())){ - LibUsbDevice device = usbManager_.aoapDevice(); - usbIconConnection_.setImageResource(R.drawable.usb); - txtUsbIconConnection_.setText(device.product); - startUsbBtn_.setEnabled(true); - startUsbBtn_.setAlpha(1f); - } else if (WIFIManager.CONNECT_WIFI.equalsIgnoreCase(intent.getAction())){ - String ssid = intent.getStringExtra(WIFIManager.EXTRA_SSID); - wifiIconConnection_.setImageResource(R.drawable.wifi); - txtWifiIconConnection_.setText(ssid); - startBtn_.setEnabled(true); - startBtn_.setAlpha(1f); - } else if (WIFIManager.DISCONNECT_WIFI.equalsIgnoreCase(intent.getAction())){ - wifiIconConnection_.setImageResource(R.drawable.wifi_dis); - txtWifiIconConnection_.setText(""); - startBtn_.setEnabled(false); - startBtn_.setAlpha(0.5f); - } + private void styleSecondaryButton(Button button, int background, int textColor) { + button.setBackgroundResource(background); + button.setTextColor(getResources().getColor(textColor)); + } + + private void renderState(ConnectionState state, String message, String mode) { + renderedState_ = state; + boolean loading = state == ConnectionState.PERMISSION_PENDING + || state == ConnectionState.AOAP_SWITCHING || state == ConnectionState.CONNECTING + || state == ConnectionState.DISCONNECTING; + boolean manualStartAvailable = state != ConnectionState.ERROR && mode != null + && ConnectionManager.instance().isManualStartAllowed(); + retryButton_.setVisibility(state == ConnectionState.ERROR || manualStartAvailable || loading + ? View.VISIBLE : View.GONE); + retryButton_.setEnabled(!loading); + retryButton_.setText(loading ? R.string.home_loading + : (state == ConnectionState.ERROR && ODAService.MODE_USB.equals(mode) + ? R.string.home_recover_usb : (state == ConnectionState.ERROR ? R.string.home_retry : R.string.home_start))); + String detail = state == ConnectionState.ERROR ? message : null; + int badge = darkTheme_ ? R.drawable.status_badge_idle : R.drawable.status_badge_idle_light; + int badgeTextColor = darkTheme_ ? R.color.oda_text_primary : R.color.home_light_text_primary; + int icon = ODAService.MODE_WIFI.equals(mode) ? R.drawable.wifi_dis : R.drawable.usb_dis; + + switch (state) { + case PERMISSION_PENDING: + statusBadge_.setText(R.string.home_status_permission); + statusTitle_.setText(R.string.home_permission_title); + detail = fallback(detail, R.string.home_permission_detail); + break; + case AOAP_SWITCHING: + statusBadge_.setText(R.string.home_status_connecting); + statusTitle_.setText(R.string.home_connecting_title); + detail = fallback(detail, R.string.home_aoap_detail); + break; + case CONNECTING: + statusBadge_.setText(R.string.home_status_connecting); + statusTitle_.setText(R.string.home_connecting_title); + detail = fallback(detail, R.string.home_connecting_detail); + break; + case DISCONNECTING: + statusBadge_.setText(R.string.home_status_stopping); + statusTitle_.setText(R.string.home_stopping_title); + detail = fallback(detail, R.string.home_stopping_detail); + break; + case ACTIVE: + badge = R.drawable.status_badge_active; + badgeTextColor = R.color.oda_text_primary; + icon = ODAService.MODE_WIFI.equals(mode) ? R.drawable.wifi : R.drawable.usb; + statusBadge_.setText(R.string.home_status_connected); + statusTitle_.setText(R.string.home_connected_title); + detail = fallback(detail, R.string.home_connected_detail); + break; + case EXITED: + statusBadge_.setText(R.string.home_status_stopped); + statusTitle_.setText(R.string.home_stopped_title); + detail = fallback(detail, R.string.home_stopped_detail); + break; + case ERROR: + badge = R.drawable.status_badge_error; + badgeTextColor = R.color.oda_text_primary; + statusBadge_.setText(R.string.home_status_error); + statusTitle_.setText(R.string.home_error_title); + detail = fallback(detail, R.string.home_error_detail); + break; + case IDLE: + default: + statusBadge_.setText(manualStartAvailable + ? R.string.home_status_ready : R.string.home_status_waiting); + statusTitle_.setText(manualStartAvailable + ? R.string.home_ready_title : R.string.home_waiting_title); + detail = fallback(detail, manualStartAvailable + ? R.string.home_ready_detail : R.string.home_waiting_detail); + break; } + statusBadge_.setBackgroundResource(badge); + statusBadge_.setTextColor(getResources().getColor(badgeTextColor)); + indicator_.setImageResource(icon); + statusDetail_.setText(detail); + if (ODAService.MODE_USB.equals(mode) && usbManager_ != null && usbManager_.aoapDevice() != null) { + usbStatus_.setText(R.string.home_usb_ready); + } + } + + private String fallback(String detail, int fallbackResource) { + return detail == null || detail.trim().isEmpty() ? getString(fallbackResource) : detail; } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/smg/hu/ui/notification/AppBadge.java b/app/src/main/java/it/smg/hu/ui/notification/AppBadge.java index 2d5eb664..a92bc70c 100644 --- a/app/src/main/java/it/smg/hu/ui/notification/AppBadge.java +++ b/app/src/main/java/it/smg/hu/ui/notification/AppBadge.java @@ -119,19 +119,15 @@ protected void initWipeAction() {} @Override public void show() { - if (Settings.instance().video.showAppBadge()) { - - Intent odaServiceIntent = new Intent(ctx_, AppBadge.class); - ctx_.startService(odaServiceIntent); - super.show(); - } + // The badge is not part of the Android Auto lifecycle. Showing it after + // projection stops permits reopening PlayerActivity before teardown is + // complete, which corrupts the next USB/TLS session. + super.dismiss(); } @Override public void dismiss() { - if (Settings.instance().video.showAppBadge()) { - super.dismiss(); - } + super.dismiss(); } @Override diff --git a/app/src/main/java/it/smg/hu/ui/settings/BaseSettingsFragment.java b/app/src/main/java/it/smg/hu/ui/settings/BaseSettingsFragment.java index 65eb688f..b87a05ba 100644 --- a/app/src/main/java/it/smg/hu/ui/settings/BaseSettingsFragment.java +++ b/app/src/main/java/it/smg/hu/ui/settings/BaseSettingsFragment.java @@ -1,6 +1,8 @@ package it.smg.hu.ui.settings; import android.content.Context; +import android.content.res.ColorStateList; +import android.view.Gravity; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; @@ -8,9 +10,13 @@ import android.widget.ArrayAdapter; import android.widget.CheckBox; import android.widget.EditText; +import android.widget.LinearLayout; import android.widget.Spinner; +import android.widget.TextView; +import android.view.ViewGroup; import androidx.fragment.app.Fragment; +import androidx.core.widget.CompoundButtonCompat; import java.util.concurrent.Callable; @@ -23,6 +29,70 @@ public abstract class BaseSettingsFragment extends Fragment { protected abstract String tag(); + @Override + public void onViewCreated(View view, android.os.Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + boolean dark = Settings.instance().appearance.darkTheme(); + view.setBackgroundColor(getResources().getColor(dark ? R.color.oda_surface : R.color.settings_surface)); + styleControls(view, dark); + } + + private void styleControls(View view, boolean dark) { + if (view instanceof ViewGroup && !(view instanceof Spinner)) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + styleControls(group.getChildAt(i), dark); + } + return; + } + int primaryColor = dark ? R.color.oda_text_primary : R.color.settings_text_primary; + int secondaryColor = dark ? R.color.oda_text_secondary : R.color.settings_text_secondary; + if (view instanceof EditText) { + EditText field = (EditText) view; + field.setTextColor(getResources().getColor(primaryColor)); + field.setHintTextColor(getResources().getColor(secondaryColor)); + field.setBackgroundResource(dark ? R.drawable.settings_input_dark : R.drawable.settings_input_light); + field.setGravity(Gravity.LEFT | Gravity.CENTER_VERTICAL); + field.setMinHeight(dp(48)); + } else if (view instanceof CheckBox) { + CheckBox checkBox = (CheckBox) view; + checkBox.setTextColor(getResources().getColor(primaryColor)); + checkBox.setGravity(Gravity.LEFT | Gravity.CENTER_VERTICAL); + int[][] states = new int[][]{ + new int[]{android.R.attr.state_checked}, new int[]{} + }; + int[] colors = new int[]{ + getResources().getColor(R.color.oda_accent), + getResources().getColor(dark ? R.color.oda_text_primary : R.color.settings_text_secondary) + }; + CompoundButtonCompat.setButtonTintList(checkBox, new ColorStateList(states, colors)); + alignToStart(checkBox, true); + view.setMinimumHeight(dp(48)); + } else if (view instanceof Spinner) { + view.setBackgroundResource(dark ? R.drawable.settings_spinner_dark : R.drawable.settings_spinner_light); + view.setMinimumHeight(dp(48)); + } else if (view instanceof TextView) { + TextView text = (TextView) view; + text.setTextColor(getResources().getColor(primaryColor)); + text.setGravity(Gravity.LEFT | Gravity.CENTER_VERTICAL); + } + } + + private void alignToStart(View view, boolean fillWidth) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (fillWidth) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + } + if (params instanceof LinearLayout.LayoutParams) { + ((LinearLayout.LayoutParams) params).gravity = Gravity.LEFT; + } + view.setLayoutParams(params); + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + protected void initEditText(EditText editText, Settings.Base base, String settingsKey, String defaultValue){ initEditText(editText, base, settingsKey, defaultValue, null); } @@ -36,7 +106,14 @@ protected void initEditText(EditText editText, Settings.Base base, String settin editText.setOnEditorActionListener((v, actionId, event) -> { if (actionId == EditorInfo.IME_ACTION_DONE){ - base.set(settingsKey, Integer.parseInt(v.getText().toString())); + String rawValue = v.getText().toString().trim(); + try { + base.set(settingsKey, Integer.parseInt(rawValue)); + v.setError(null); + } catch (NumberFormatException error) { + v.setError(getString(R.string.settings_invalid_number)); + return true; + } InputMethodManager imm = (InputMethodManager) v.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(v.getWindowToken(), 0); @@ -103,8 +180,7 @@ protected void initSpinner(Spinner spinner, int data, int dataValue, Settings.Ba } protected void initSpinner(Spinner spinner, int data, int dataValue, Settings.Base base, String settingsKey, int defaultValue, Callable custonCheck){ - ArrayAdapter adapter = ArrayAdapter.createFromResource(getContext(), data, R.layout.spinner_item); - adapter.setDropDownViewResource(R.layout.spinner_dropdown_item); + ArrayAdapter adapter = createSpinnerAdapter(data); spinner.setAdapter(adapter); int value = base.get(settingsKey, defaultValue); @@ -146,8 +222,7 @@ public void onNothingSelected(AdapterView parent) { } protected void initSpinner(Spinner spinner, int data, int dataValue, Settings.Base base, String settingsKey, String defaultValue, Callable custonCheck){ - ArrayAdapter adapter = ArrayAdapter.createFromResource(getContext(), data, R.layout.spinner_item); - adapter.setDropDownViewResource(R.layout.spinner_dropdown_item); + ArrayAdapter adapter = createSpinnerAdapter(data); spinner.setAdapter(adapter); String value = base.get(settingsKey, defaultValue); @@ -187,4 +262,13 @@ public void onNothingSelected(AdapterView parent) { } }); } + + private ArrayAdapter createSpinnerAdapter(int data) { + boolean dark = Settings.instance().appearance.darkTheme(); + ArrayAdapter adapter = ArrayAdapter.createFromResource(getContext(), data, + dark ? R.layout.spinner_item_dark : R.layout.spinner_item); + adapter.setDropDownViewResource(dark + ? R.layout.spinner_dropdown_item_dark : R.layout.spinner_dropdown_item); + return adapter; + } } diff --git a/app/src/main/java/it/smg/hu/ui/settings/KeymapFragment.java b/app/src/main/java/it/smg/hu/ui/settings/KeymapFragment.java index 1185a175..1737b371 100644 --- a/app/src/main/java/it/smg/hu/ui/settings/KeymapFragment.java +++ b/app/src/main/java/it/smg/hu/ui/settings/KeymapFragment.java @@ -41,9 +41,13 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa if (settings.advanced.hondaIntegrationEnabled()){ if (Log.isVerbose()) Log.v(TAG, "init hondaconnection and keyholder"); HondaConnectManager.instance().initialize(); - - HondaConnectManager.instance().requestAudioFocus(); keyHolder_ = (InputDevice.OnKeyHolder) getActivity(); + handler_.postDelayed(() -> { + if (isAdded() && settings.advanced.hondaIntegrationEnabled() + && HondaConnectManager.instance() != null) { + HondaConnectManager.instance().requestAudioFocus(); + } + }, 500); } unsetKeyListener_ = () -> { @@ -126,6 +130,9 @@ public void onClick(View v) { @Override public void onStop() { if (Log.isVerbose()) Log.v(TAG, "onStop"); + if (handler_ != null) { + handler_.removeCallbacksAndMessages(null); + } if (settings.advanced.hondaIntegrationEnabled()){ HondaConnectManager.instance().endAudioBinding(); } @@ -202,4 +209,4 @@ public int defaultAction(){ } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/smg/hu/ui/settings/VideoFragment.java b/app/src/main/java/it/smg/hu/ui/settings/VideoFragment.java index ef033582..7a6087a2 100644 --- a/app/src/main/java/it/smg/hu/ui/settings/VideoFragment.java +++ b/app/src/main/java/it/smg/hu/ui/settings/VideoFragment.java @@ -93,9 +93,6 @@ public Void call() { CheckBox showNavigationNotification = view.findViewById(R.id.showNavigationNotification); initCheckBox(showNavigationNotification, settings.video, Settings.Video.VIDEO_SHOW_NAVIGATION_NOTIFICATION, Settings.Video.VIDEO_SHOW_NAVIGATION_NOTIFICATION_DEFAULT_VALUE); - CheckBox showAppBadge = view.findViewById(R.id.showAppBadge); - initCheckBox(showAppBadge, settings.video, Settings.Video.VIDEO_SHOW_APP_BADGE, Settings.Video.VIDEO_SHOW_APP_BADGE_DEFAULT_VALUE); - return view; } diff --git a/app/src/main/res/drawable/button_primary.xml b/app/src/main/res/drawable/button_primary.xml new file mode 100644 index 00000000..8fe30bb4 --- /dev/null +++ b/app/src/main/res/drawable/button_primary.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/button_secondary.xml b/app/src/main/res/drawable/button_secondary.xml new file mode 100644 index 00000000..376fb7cf --- /dev/null +++ b/app/src/main/res/drawable/button_secondary.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/button_secondary_light.xml b/app/src/main/res/drawable/button_secondary_light.xml new file mode 100644 index 00000000..bde0cce4 --- /dev/null +++ b/app/src/main/res/drawable/button_secondary_light.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/panel_surface.xml b/app/src/main/res/drawable/panel_surface.xml new file mode 100644 index 00000000..5be6fa78 --- /dev/null +++ b/app/src/main/res/drawable/panel_surface.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/panel_surface_light.xml b/app/src/main/res/drawable/panel_surface_light.xml new file mode 100644 index 00000000..b1ecd523 --- /dev/null +++ b/app/src/main/res/drawable/panel_surface_light.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/settings_input_dark.xml b/app/src/main/res/drawable/settings_input_dark.xml new file mode 100644 index 00000000..1dae4ec3 --- /dev/null +++ b/app/src/main/res/drawable/settings_input_dark.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/settings_input_light.xml b/app/src/main/res/drawable/settings_input_light.xml new file mode 100644 index 00000000..d2d72368 --- /dev/null +++ b/app/src/main/res/drawable/settings_input_light.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/settings_spinner_dark.xml b/app/src/main/res/drawable/settings_spinner_dark.xml new file mode 100644 index 00000000..1dae4ec3 --- /dev/null +++ b/app/src/main/res/drawable/settings_spinner_dark.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/settings_spinner_light.xml b/app/src/main/res/drawable/settings_spinner_light.xml new file mode 100644 index 00000000..d2d72368 --- /dev/null +++ b/app/src/main/res/drawable/settings_spinner_light.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/status_badge_active.xml b/app/src/main/res/drawable/status_badge_active.xml new file mode 100644 index 00000000..2273df5f --- /dev/null +++ b/app/src/main/res/drawable/status_badge_active.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/status_badge_error.xml b/app/src/main/res/drawable/status_badge_error.xml new file mode 100644 index 00000000..f4a12492 --- /dev/null +++ b/app/src/main/res/drawable/status_badge_error.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/status_badge_idle.xml b/app/src/main/res/drawable/status_badge_idle.xml new file mode 100644 index 00000000..feb15eb1 --- /dev/null +++ b/app/src/main/res/drawable/status_badge_idle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/status_badge_idle_light.xml b/app/src/main/res/drawable/status_badge_idle_light.xml new file mode 100644 index 00000000..92bd5dc4 --- /dev/null +++ b/app/src/main/res/drawable/status_badge_idle_light.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index f1eb6ddf..a248e10f 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -1,95 +1,149 @@ - + - + + +