1	package com.qsjnic.mcu.sdk;
2
3	import android.content.ComponentName;
4	import android.content.Context;
5	import android.content.Intent;
6	import android.content.ServiceConnection;
7	import android.os.Handler;
8	import android.os.IBinder;
9	import android.os.Looper;
10	import android.os.RemoteException;
11	import android.util.Log;
12
13	import androidx.annotation.NonNull;
14	import androidx.annotation.Nullable;
15
16	import com.qsjnic.mcu.contract.IMcuFrameListener;
17	import com.qsjnic.mcu.contract.IMcuService;
18	import com.qsjnic.mcu.contract.McuErrorCode;
19	import com.qsjnic.mcu.contract.McuServiceManager;
20
21	import java.util.concurrent.CopyOnWriteArrayList;
22
23	/**
24	 * Customer-facing entry point for the QSJNIC MCU SDK.
25	 *
26	 *
Step C3 minimal version exposes only the handshake methods
27	 * ({@link #getAidlVersion()} / {@link #isLinkAlive()}) so we can prove
28	 * end-to-end Binder IPC. Domain Facades (Vehicle / Radio / Upgrade)
29	 * arrive in D phase.
30	 *
31	 *
Usage:
32	 *

33	 *   McuClient.getInstance().init(applicationContext);
34	 *   McuClient.getInstance().addReadyListener(() -> {
35	 *       int v = McuClient.getInstance().getAidlVersion();
36	 *       boolean live = McuClient.getInstance().isLinkAlive();
37	 *   });
38	 *

39	 */
40	public final class McuClient {
41
42	    private static final String TAG = "McuClient";
43
44	    private static final McuClient INSTANCE = new McuClient();
45
46	    public static McuClient getInstance() { return INSTANCE; }
47
48	    private final Object lock = new Object();
49	    private final Handler mainHandler = new Handler(Looper.getMainLooper());
50	    private final CopyOnWriteArrayList<ReadyListener> readyListeners = new CopyOnWriteArrayList<>();
51
52	    private final McuVehicleClient  vehicle  = new McuVehicleClient(this);
53	    private final McuKeyClient      key      = new McuKeyClient(this);
54	    private final McuRadioClient    radio    = new McuRadioClient(this);
55	    private final McuUpgradeClient  upgrade  = new McuUpgradeClient(this);
56
57	    private Context     appContext;
58	    private IMcuService service;
59	    private int bindPackageIndex = 0;
60
61	    private static final String[] SERVICE_PACKAGES = {
62	            McuServiceManager.SERVICE_PACKAGE,
63	            McuServiceManager.SERVICE_PACKAGE + ".dev",
64	            McuServiceManager.SERVICE_PACKAGE + ".test"
65	    };
66
67	    /** Re-register unified frame listener after reconnect. */
68	    private volatile boolean frameListenerRegistered = false;
69	    private final IMcuFrameListener frameListenerStub = new IMcuFrameListener.Stub() {
70	        @Override public void onMcuFrame(int ft, int mt, int cmd, byte[] body) {}
71	        @Override public void onVehicleStateChanged(com.qsjnic.mcu.contract.VehicleSnapshot s) {}
72	        @Override public void onRadioStateChanged(com.qsjnic.mcu.contract.RadioState s) {}
73	        @Override public void onUpgradeProgress(com.qsjnic.mcu.contract.UpgradeProgress p) {}
74	    };
75
76	    private final ServiceConnection conn = new ServiceConnection() {
77	        @Override
78	        public void onServiceConnected(ComponentName name, IBinder binder) {
79	            Log.i(TAG, "onServiceConnected " + name);
80	            try {
81	                binder.linkToDeath(deathRecipient, 0);
82	            } catch (RemoteException e) {
83	                Log.e(TAG, "linkToDeath failed, binder already dead — rebind", e);
84	                synchronized (lock) { service = null; }
85	                mainHandler.postDelayed(McuClient.this::bind, 1000);
86	                return;
87	            }
88	            IMcuService svc = IMcuService.Stub.asInterface(binder);
89	            synchronized (lock) {
90	                service = svc;
91	            }
92	            // Version diagnostic on bind:dual-source verification
93	            Log.i(TAG, "SDK_VERSION (local constant) = " + getSdkVersion());
94	            try {
95	                Log.i(TAG, "Service version (remote IPC) = " + svc.getServiceVersion());
96	                Log.i(TAG, "AIDL surface version       = " + svc.getAidlVersion());
97	            } catch (RemoteException e) {
98	                Log.w(TAG, "version probe via binder failed", e);
99	            }
100	            // E-3: re-register frame listener after reconnect
101	            if (frameListenerRegistered) {
102	                try { svc.registerFrameListener(frameListenerStub); }
103	                catch (RemoteException e) { Log.w(TAG, "re-register frame listener failed", e); }
104	            }
105	            for (ReadyListener l : readyListeners) {
106	                try { l.onReady(); } catch (Throwable t) { Log.w(TAG, "ReadyListener threw", t); }
107	            }
108	        }
109
110	        @Override
111	        public void onServiceDisconnected(ComponentName name) {
112	            Log.w(TAG, "onServiceDisconnected " + name);
113	            synchronized (lock) { service = null; }
114	        }
115	    };
116
117	    private final IBinder.DeathRecipient deathRecipient = new IBinder.DeathRecipient() {
118	        @Override
119	        public void binderDied() {
120	            Log.w(TAG, "Service binder died, scheduling rebind");
121	            synchronized (lock) { service = null; }
122	            // Simple immediate rebind for C3. D phase adds exponential backoff
123	            // and re-registers Listeners.
124	            mainHandler.postDelayed(McuClient.this::bind, 1000);
125	        }
126	    };
127
128	    private McuClient() {}
129
130	    /** Bind to the Service. Idempotent. */
131	    public void init(@NonNull Context context) {
132	        synchronized (lock) {
133	            if (this.appContext != null) {
134	                Log.w(TAG, "init() called twice, ignoring");
135	                return;
136	            }
137	            this.appContext = context.getApplicationContext();
138	        }
139	        bind();
140	    }
141
142	    /** Unbind. Mainly for tests. */
143	    public void release() {
144	        synchronized (lock) {
145	            if (appContext != null && service != null) {
146	                try { appContext.unbindService(conn); } catch (Throwable t) {/*best effort*/}
147	            }
148	            service = null;
149	        }
150	    }
151
152	    private void bind() {
153	        Context ctx;
154	        synchronized (lock) { ctx = appContext; }
155	        if (ctx == null) return;
156
157	        Intent i = new Intent(McuServiceManager.SERVICE_INTENT_ACTION);
158	        String pkg = SERVICE_PACKAGES[bindPackageIndex % SERVICE_PACKAGES.length];
159	        i.setPackage(pkg);
160	        boolean ok = ctx.bindService(i, conn, Context.BIND_AUTO_CREATE);
161	        Log.i(TAG, "bindService(" + McuServiceManager.SERVICE_INTENT_ACTION
162	                + " pkg=" + pkg + ") -> " + ok);
163	        if (!ok) {
164	            // Service may not yet be installed/refreshed; retry shortly.
165	            bindPackageIndex++;
166	            mainHandler.postDelayed(this::bind, 2000);
167	        }
168	    }
169
170	    public boolean isReady() {
171	        synchronized (lock) { return service != null; }
172	    }
173
174	    /** Vehicle / Public domain Facade. */
175	    public McuVehicleClient vehicle() { return vehicle; }
176
177	    /** Key domain Facade. */
178	    public McuKeyClient key() { return key; }
179
180	    /** Radio domain Facade. */
181	    public McuRadioClient radio() { return radio; }
182
183	    /** Upgrade / OTA domain Facade. */
184	    public McuUpgradeClient upgrade() { return upgrade; }
185
186	    /**
187	     * Internal accessor for domain Facades. Returns null if Service not bound.
188	     * Not part of the public SDK API.
189	     */
190	    @Nullable
191	    IMcuService getServiceUnchecked() {
192	        synchronized (lock) { return service; }
193	    }
194
195	    public void addReadyListener(@NonNull ReadyListener l) {
196	        readyListeners.add(l);
197	        if (isReady()) {
198	            try { l.onReady(); } catch (Throwable ignored) {}
199	        }
200	    }
201
202	    public void removeReadyListener(@Nullable ReadyListener l) {
203	        if (l != null) readyListeners.remove(l);
204	    }
205
206	    /** Returns AIDL surface version of the bound Service, or -1 if not bound. */
207	    public int getAidlVersion() {
208	        IMcuService s;
209	        synchronized (lock) { s = service; }
210	        if (s == null) return -1;
211	        try {
212	            return s.getAidlVersion();
213	        } catch (RemoteException e) {
214	            Log.e(TAG, "getAidlVersion failed", e);
215	            return -1;
216	        }
217	    }
218
219	    /** Returns the SDK build version compiled into qsmw-sdk.aar (no IPC required).
220	     *  Use this to confirm which qsmw-sdk.aar your app is linked against. */
221	    public static String getSdkVersion() {
222	        return McuServiceManager.SDK_VERSION;
223	    }
224
225	    /** Returns the live Service-side build version via IPC, or null if not bound.
226	     *  Useful for asserting Service and SDK versions match in the field. */
227	    @Nullable
228	    public String getRemoteServiceVersion() {
229	        IMcuService s;
230	        synchronized (lock) { s = service; }
231	        if (s == null) return null;
232	        try {
233	            return s.getServiceVersion();
234	        } catch (RemoteException e) {
235	            Log.e(TAG, "getServiceVersion failed", e);
236	            return null;
237	        }
238	    }
239
240	    public boolean isLinkAlive() {
241	        IMcuService s;
242	        synchronized (lock) { s = service; }
243	        if (s == null) return false;
244	        try {
245	            return s.isLinkAlive();
246	        } catch (RemoteException e) {
247	            Log.e(TAG, "isLinkAlive failed", e);
248	            return false;
249	        }
250	    }
251
252	    // ===== AIDL v2 generic command channel =====
253
254	    public int sendCommand(int msgType, int cmd, byte[] body, int timeoutMs) {
255	        IMcuService s = getServiceUnchecked();
256	        if (s == null) {
257	            Log.w(TAG, String.format("sendCommand mt=0x%02X cmd=0x%02X — service not bound", msgType, cmd));
258	            return McuErrorCode.ERR_SERVICE;
259	        }
260	        try {
261	            int rc = s.sendCommand(msgType, cmd, body, timeoutMs);
262	            Log.i(TAG, String.format("sendCommand mt=0x%02X cmd=0x%02X bodyLen=%d -> rc=%d",
263	                    msgType, cmd, body == null ? 0 : body.length, rc));
264	            return rc;
265	        } catch (RemoteException e) {
266	            Log.e(TAG, String.format("sendCommand mt=0x%02X cmd=0x%02X RemoteException", msgType, cmd), e);
267	            return McuErrorCode.ERR_LINK_DOWN;
268	        }
269	    }
270
271	    public void postCommand(int msgType, int cmd, byte[] body) {
272	        IMcuService s = getServiceUnchecked();
273	        if (s == null) return;
274	        try { s.postCommand(msgType, cmd, body); } catch (RemoteException ignored) {}
275	    }
276
277	    public void addFrameListener(IMcuFrameListener cb) {
278	        IMcuService s = getServiceUnchecked();
279	        if (s == null) return;
280	        try { s.registerFrameListener(cb); } catch (RemoteException ignored) {}
281	    }
282
283	    public void removeFrameListener(IMcuFrameListener cb) {
284	        IMcuService s = getServiceUnchecked();
285	        if (s == null) return;
286	        try { s.unregisterFrameListener(cb); } catch (RemoteException ignored) {}
287	    }
288
289	    public interface ReadyListener {
290	        void onReady();
291	    }
292	}
293

package com.qsjnic.mcu.sdk;

import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.qsjnic.mcu.contract.IMcuFrameListener;
import com.qsjnic.mcu.contract.IMcuService;
import com.qsjnic.mcu.contract.McuErrorCode;
import com.qsjnic.mcu.contract.McuServiceManager;

import java.util.concurrent.CopyOnWriteArrayList;

/**
 * Customer-facing entry point for the QSJNIC MCU SDK.
 *
 *
Step C3 minimal version exposes only the handshake methods
 * ({@link #getAidlVersion()} / {@link #isLinkAlive()}) so we can prove
 * end-to-end Binder IPC. Domain Facades (Vehicle / Radio / Upgrade)
 * arrive in D phase.
 *
 *
Usage:
 *

 *   McuClient.getInstance().init(applicationContext);
 *   McuClient.getInstance().addReadyListener(() -> {
 *       int v = McuClient.getInstance().getAidlVersion();
 *       boolean live = McuClient.getInstance().isLinkAlive();
 *   });
 *

 */
public final class McuClient {

    private static final String TAG = "McuClient";

    private static final McuClient INSTANCE = new McuClient();

    public static McuClient getInstance() { return INSTANCE; }

    private final Object lock = new Object();
    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    private final CopyOnWriteArrayList<ReadyListener> readyListeners = new CopyOnWriteArrayList<>();

    private final McuVehicleClient  vehicle  = new McuVehicleClient(this);
    private final McuKeyClient      key      = new McuKeyClient(this);
    private final McuRadioClient    radio    = new McuRadioClient(this);
    private final McuUpgradeClient  upgrade  = new McuUpgradeClient(this);

    private Context     appContext;
    private IMcuService service;
    private int bindPackageIndex = 0;

    private static final String[] SERVICE_PACKAGES = {
            McuServiceManager.SERVICE_PACKAGE,
            McuServiceManager.SERVICE_PACKAGE + ".dev",
            McuServiceManager.SERVICE_PACKAGE + ".test"
    };

    /** Re-register unified frame listener after reconnect. */
    private volatile boolean frameListenerRegistered = false;
    private final IMcuFrameListener frameListenerStub = new IMcuFrameListener.Stub() {
        @Override public void onMcuFrame(int ft, int mt, int cmd, byte[] body) {}
        @Override public void onVehicleStateChanged(com.qsjnic.mcu.contract.VehicleSnapshot s) {}
        @Override public void onRadioStateChanged(com.qsjnic.mcu.contract.RadioState s) {}
        @Override public void onUpgradeProgress(com.qsjnic.mcu.contract.UpgradeProgress p) {}
    };

    private final ServiceConnection conn = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder binder) {
            Log.i(TAG, "onServiceConnected " + name);
            try {
                binder.linkToDeath(deathRecipient, 0);
            } catch (RemoteException e) {
                Log.e(TAG, "linkToDeath failed, binder already dead — rebind", e);
                synchronized (lock) { service = null; }
                mainHandler.postDelayed(McuClient.this::bind, 1000);
                return;
            }
            IMcuService svc = IMcuService.Stub.asInterface(binder);
            synchronized (lock) {
                service = svc;
            }
            // Version diagnostic on bind:dual-source verification
            Log.i(TAG, "SDK_VERSION (local constant) = " + getSdkVersion());
            try {
                Log.i(TAG, "Service version (remote IPC) = " + svc.getServiceVersion());
                Log.i(TAG, "AIDL surface version       = " + svc.getAidlVersion());
            } catch (RemoteException e) {
                Log.w(TAG, "version probe via binder failed", e);
            }
            // E-3: re-register frame listener after reconnect
            if (frameListenerRegistered) {
                try { svc.registerFrameListener(frameListenerStub); }
                catch (RemoteException e) { Log.w(TAG, "re-register frame listener failed", e); }
            }
            for (ReadyListener l : readyListeners) {
                try { l.onReady(); } catch (Throwable t) { Log.w(TAG, "ReadyListener threw", t); }
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            Log.w(TAG, "onServiceDisconnected " + name);
            synchronized (lock) { service = null; }
        }
    };

    private final IBinder.DeathRecipient deathRecipient = new IBinder.DeathRecipient() {
        @Override
        public void binderDied() {
            Log.w(TAG, "Service binder died, scheduling rebind");
            synchronized (lock) { service = null; }
            // Simple immediate rebind for C3. D phase adds exponential backoff
            // and re-registers Listeners.
            mainHandler.postDelayed(McuClient.this::bind, 1000);
        }
    };

    private McuClient() {}

    /** Bind to the Service. Idempotent. */
    public void init(@NonNull Context context) {
        synchronized (lock) {
            if (this.appContext != null) {
                Log.w(TAG, "init() called twice, ignoring");
                return;
            }
            this.appContext = context.getApplicationContext();
        }
        bind();
    }

    /** Unbind. Mainly for tests. */
    public void release() {
        synchronized (lock) {
            if (appContext != null && service != null) {
                try { appContext.unbindService(conn); } catch (Throwable t) {/*best effort*/}
            }
            service = null;
        }
    }

    private void bind() {
        Context ctx;
        synchronized (lock) { ctx = appContext; }
        if (ctx == null) return;

        Intent i = new Intent(McuServiceManager.SERVICE_INTENT_ACTION);
        String pkg = SERVICE_PACKAGES[bindPackageIndex % SERVICE_PACKAGES.length];
        i.setPackage(pkg);
        boolean ok = ctx.bindService(i, conn, Context.BIND_AUTO_CREATE);
        Log.i(TAG, "bindService(" + McuServiceManager.SERVICE_INTENT_ACTION
                + " pkg=" + pkg + ") -> " + ok);
        if (!ok) {
            // Service may not yet be installed/refreshed; retry shortly.
            bindPackageIndex++;
            mainHandler.postDelayed(this::bind, 2000);
        }
    }

    public boolean isReady() {
        synchronized (lock) { return service != null; }
    }

    /** Vehicle / Public domain Facade. */
    public McuVehicleClient vehicle() { return vehicle; }

    /** Key domain Facade. */
    public McuKeyClient key() { return key; }

    /** Radio domain Facade. */
    public McuRadioClient radio() { return radio; }

    /** Upgrade / OTA domain Facade. */
    public McuUpgradeClient upgrade() { return upgrade; }

    /**
     * Internal accessor for domain Facades. Returns null if Service not bound.
     * Not part of the public SDK API.
     */
    @Nullable
    IMcuService getServiceUnchecked() {
        synchronized (lock) { return service; }
    }

    public void addReadyListener(@NonNull ReadyListener l) {
        readyListeners.add(l);
        if (isReady()) {
            try { l.onReady(); } catch (Throwable ignored) {}
        }
    }

    public void removeReadyListener(@Nullable ReadyListener l) {
        if (l != null) readyListeners.remove(l);
    }

    /** Returns AIDL surface version of the bound Service, or -1 if not bound. */
    public int getAidlVersion() {
        IMcuService s;
        synchronized (lock) { s = service; }
        if (s == null) return -1;
        try {
            return s.getAidlVersion();
        } catch (RemoteException e) {
            Log.e(TAG, "getAidlVersion failed", e);
            return -1;
        }
    }

    /** Returns the SDK build version compiled into qsmw-sdk.aar (no IPC required).
     *  Use this to confirm which qsmw-sdk.aar your app is linked against. */
    public static String getSdkVersion() {
        return McuServiceManager.SDK_VERSION;
    }

    /** Returns the live Service-side build version via IPC, or null if not bound.
     *  Useful for asserting Service and SDK versions match in the field. */
    @Nullable
    public String getRemoteServiceVersion() {
        IMcuService s;
        synchronized (lock) { s = service; }
        if (s == null) return null;
        try {
            return s.getServiceVersion();
        } catch (RemoteException e) {
            Log.e(TAG, "getServiceVersion failed", e);
            return null;
        }
    }

    public boolean isLinkAlive() {
        IMcuService s;
        synchronized (lock) { s = service; }
        if (s == null) return false;
        try {
            return s.isLinkAlive();
        } catch (RemoteException e) {
            Log.e(TAG, "isLinkAlive failed", e);
            return false;
        }
    }

    // ===== AIDL v2 generic command channel =====

    public int sendCommand(int msgType, int cmd, byte[] body, int timeoutMs) {
        IMcuService s = getServiceUnchecked();
        if (s == null) {
            Log.w(TAG, String.format("sendCommand mt=0x%02X cmd=0x%02X — service not bound", msgType, cmd));
            return McuErrorCode.ERR_SERVICE;
        }
        try {
            int rc = s.sendCommand(msgType, cmd, body, timeoutMs);
            Log.i(TAG, String.format("sendCommand mt=0x%02X cmd=0x%02X bodyLen=%d -> rc=%d",
                    msgType, cmd, body == null ? 0 : body.length, rc));
            return rc;
        } catch (RemoteException e) {
            Log.e(TAG, String.format("sendCommand mt=0x%02X cmd=0x%02X RemoteException", msgType, cmd), e);
            return McuErrorCode.ERR_LINK_DOWN;
        }
    }

    public void postCommand(int msgType, int cmd, byte[] body) {
        IMcuService s = getServiceUnchecked();
        if (s == null) return;
        try { s.postCommand(msgType, cmd, body); } catch (RemoteException ignored) {}
    }

    public void addFrameListener(IMcuFrameListener cb) {
        IMcuService s = getServiceUnchecked();
        if (s == null) return;
        try { s.registerFrameListener(cb); } catch (RemoteException ignored) {}
    }

    public void removeFrameListener(IMcuFrameListener cb) {
        IMcuService s = getServiceUnchecked();
        if (s == null) return;
        try { s.unregisterFrameListener(cb); } catch (RemoteException ignored) {}
    }

    public interface ReadyListener {
        void onReady();
    }
}


1	package com.qsjnic.mcu.sdk;
2
3	import android.os.RemoteException;
4	import android.util.Log;
5
6	import androidx.annotation.NonNull;
7	import androidx.annotation.Nullable;
8
9	import com.qsjnic.mcu.contract.IMcuRadioListener;
10	import com.qsjnic.mcu.contract.IMcuService;
11	import com.qsjnic.mcu.contract.McuErrorCode;
12	import com.qsjnic.mcu.contract.RadioState;
13
14	import java.util.concurrent.CopyOnWriteArrayList;
15
16	/**
17	 * Radio domain Facade. Mirrors {@link McuVehicleClient}.
18	 */
19	public final class McuRadioClient {
20
21	    private static final String TAG = "McuRadioClient";
22
23	    public static final int BAND_FM = 0;
24	    public static final int BAND_AM = 1;
25
26	    /** Seek / search modes (match Radio protocol 0x81). */
27	    public static final int SEEK_STOP     = 0;
28	    public static final int SEEK_AMS      = 1; // 全频段自动搜存
29	    public static final int SEEK_FORWARD  = 2; // 单步向前
30	    public static final int SEEK_BACKWARD = 3; // 单步向后
31	    public static final int SEEK_PTY      = 4; // 按节目类型搜
32	    public static final int SEEK_TA       = 5; // 交通公告搜
33
34	    /** Switch sub-types for {@link #setSwitch(int, int)} (Radio 0x82). */
35	    public static final int SWITCH_TA        = 0; // 0=关, 1=开
36	    public static final int SWITCH_AF        = 1; // 0=关, 1=开
37	    public static final int SWITCH_STEREO    = 2; // 0=关, 1=开
38	    public static final int SWITCH_LOCAL     = 3; // 0=远程(DX), 1=近程(LOC)
39	    public static final int SWITCH_RDS       = 4; // 0=关闭全部RDS, 1=开启
40	    public static final int SWITCH_CT        = 5; // 0=关, 1=开 (CT时钟同步)
41
42	    private final McuClient parent;
43	    private final CopyOnWriteArrayList<RadioListener> javaListeners = new CopyOnWriteArrayList<>();
44
45	    private final IMcuRadioListener.Stub aidlListener = new IMcuRadioListener.Stub() {
46	        @Override public void onFreqChanged(RadioState s) {
47	            for (RadioListener l : javaListeners) safe(() -> l.onFreqChanged(s));
48	        }
49	        @Override public void onPsPty(int pi, int pty, String ps) {
50	            for (RadioListener l : javaListeners) safe(() -> l.onPsPty(pi, pty, ps));
51	        }
52	        @Override public void onRadioText(String rt) {
53	            for (RadioListener l : javaListeners) safe(() -> l.onRadioText(rt));
54	        }
55	        @Override public void onTaPtyTips(int mode, int value) {
56	            for (RadioListener l : javaListeners) safe(() -> l.onTaPtyTips(mode, value));
57	        }
58	        @Override public void onWorkStatus(int status) {
59	            for (RadioListener l : javaListeners) safe(() -> l.onWorkStatus(status));
60	        }
61	    };
62
63	    private boolean registeredWithService = false;
64
65	    McuRadioClient(@NonNull McuClient parent) {
66	        this.parent = parent;
67	        parent.addReadyListener(this::onServiceReady);
68	    }
69
70	    private void onServiceReady() {
71	        registeredWithService = false;
72	        if (!javaListeners.isEmpty()) ensureRegistered();
73	    }
74
75	    private void ensureRegistered() {
76	        if (registeredWithService) return;
77	        IMcuService svc = parent.getServiceUnchecked();
78	        if (svc == null) return;
79	        try {
80	            svc.registerRadioListener(aidlListener);
81	            registeredWithService = true;
82	            Log.i(TAG, "registered radio listener with Service");
83	        } catch (RemoteException e) {
84	            Log.e(TAG, "registerRadioListener failed", e);
85	        }
86	    }
87
88	    public void addListener(@NonNull RadioListener l) {
89	        javaListeners.add(l);
90	        ensureRegistered();
91	    }
92
93	    public void removeListener(@Nullable RadioListener l) {
94	        if (l != null) javaListeners.remove(l);
95	    }
96
97	    @Nullable
98	    public RadioState getLatestState() {
99	        IMcuService svc = parent.getServiceUnchecked();
100	        if (svc == null) return null;
101	        try { return svc.getLatestRadioState(); }
102	        catch (RemoteException e) { Log.e(TAG, "getLatestRadioState failed", e); return null; }
103	    }
104
105	    public int setBand(int band) {
106	        return setBand(band, 0);
107	    }
108
109	    public int setBand(int band, int freq) {
110	        IMcuService s = parent.getServiceUnchecked();
111	        if (s == null) return McuErrorCode.ERR_SERVICE;
112	        try { return s.setBandWithFreq(band, freq); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
113	    }
114
115	    public int setFrequency(int freq) {
116	        IMcuService s = parent.getServiceUnchecked();
117	        if (s == null) return McuErrorCode.ERR_SERVICE;
118	        try { return s.setFrequency(freq); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
119	    }
120
121	    public int setFreqRange(int area, int amMin, int amMax,
122	                            int fmMin, int fmMax, int amStep, int fmStep) {
123	        IMcuService s = parent.getServiceUnchecked();
124	        if (s == null) return McuErrorCode.ERR_SERVICE;
125	        try { return s.setFreqRange(area, amMin, amMax, fmMin, fmMax, amStep, fmStep); }
126	        catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
127	    }
128
129	    public int seekStop()      { return setSeek(SEEK_STOP,     0); }
130	    public int seekAms()       { return setSeek(SEEK_AMS,      0); }
131	    public int seekForward()   { return setSeek(SEEK_FORWARD,  0); }
132	    public int seekBackward()  { return setSeek(SEEK_BACKWARD, 0); }
133	    public int seekPty(int pty){ return setSeek(SEEK_PTY,      pty); }
134	    public int seekTa()        { return setSeek(SEEK_TA,       0); }
135
136	    public int setSeek(int mode, int pty) {
137	        IMcuService s = parent.getServiceUnchecked();
138	        if (s == null) return McuErrorCode.ERR_SERVICE;
139	        try { return s.setSeek(mode, pty); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
140	    }
141
142	    /**
143	     * Radio 0x82 SWITCH_ON_OFF.
144	     *
145	     * @param subType 开关类型：{@link #SWITCH_TA}=0, {@link #SWITCH_AF}=1,
146	     *                {@link #SWITCH_STEREO}=2, {@link #SWITCH_LOCAL}=3,
147	     *                {@link #SWITCH_RDS}=4, {@link #SWITCH_CT}=5
148	     * @param value   0=关/关闭/远程，1=开/开启/近程（取决于 subType）
149	     */
150	    public int setSwitch(int subType, int value) {
151	        IMcuService s = parent.getServiceUnchecked();
152	        if (s == null) return McuErrorCode.ERR_SERVICE;
153	        try { return s.setSwitch(subType, value); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
154	    }
155
156	    public int setAntPower(boolean on) {
157	        IMcuService s = parent.getServiceUnchecked();
158	        if (s == null) return McuErrorCode.ERR_SERVICE;
159	        try { return s.setAntPower(on); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
160	    }
161
162	    public int setRegMute(boolean mute) {
163	        IMcuService s = parent.getServiceUnchecked();
164	        if (s == null) return McuErrorCode.ERR_SERVICE;
165	        try { return s.setRegMute(mute); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
166	    }
167
168	    private static void safe(Runnable r) {
169	        try { r.run(); } catch (Throwable t) { Log.w(TAG, "listener threw", t); }
170	    }
171	}
172

package com.qsjnic.mcu.sdk;

import android.os.RemoteException;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.qsjnic.mcu.contract.IMcuRadioListener;
import com.qsjnic.mcu.contract.IMcuService;
import com.qsjnic.mcu.contract.McuErrorCode;
import com.qsjnic.mcu.contract.RadioState;

import java.util.concurrent.CopyOnWriteArrayList;

/**
 * Radio domain Facade. Mirrors {@link McuVehicleClient}.
 */
public final class McuRadioClient {

    private static final String TAG = "McuRadioClient";

    public static final int BAND_FM = 0;
    public static final int BAND_AM = 1;

    /** Seek / search modes (match Radio protocol 0x81). */
    public static final int SEEK_STOP     = 0;
    public static final int SEEK_AMS      = 1; // 全频段自动搜存
    public static final int SEEK_FORWARD  = 2; // 单步向前
    public static final int SEEK_BACKWARD = 3; // 单步向后
    public static final int SEEK_PTY      = 4; // 按节目类型搜
    public static final int SEEK_TA       = 5; // 交通公告搜

    /** Switch sub-types for {@link #setSwitch(int, int)} (Radio 0x82). */
    public static final int SWITCH_TA        = 0; // 0=关, 1=开
    public static final int SWITCH_AF        = 1; // 0=关, 1=开
    public static final int SWITCH_STEREO    = 2; // 0=关, 1=开
    public static final int SWITCH_LOCAL     = 3; // 0=远程(DX), 1=近程(LOC)
    public static final int SWITCH_RDS       = 4; // 0=关闭全部RDS, 1=开启
    public static final int SWITCH_CT        = 5; // 0=关, 1=开 (CT时钟同步)

    private final McuClient parent;
    private final CopyOnWriteArrayList<RadioListener> javaListeners = new CopyOnWriteArrayList<>();

    private final IMcuRadioListener.Stub aidlListener = new IMcuRadioListener.Stub() {
        @Override public void onFreqChanged(RadioState s) {
            for (RadioListener l : javaListeners) safe(() -> l.onFreqChanged(s));
        }
        @Override public void onPsPty(int pi, int pty, String ps) {
            for (RadioListener l : javaListeners) safe(() -> l.onPsPty(pi, pty, ps));
        }
        @Override public void onRadioText(String rt) {
            for (RadioListener l : javaListeners) safe(() -> l.onRadioText(rt));
        }
        @Override public void onTaPtyTips(int mode, int value) {
            for (RadioListener l : javaListeners) safe(() -> l.onTaPtyTips(mode, value));
        }
        @Override public void onWorkStatus(int status) {
            for (RadioListener l : javaListeners) safe(() -> l.onWorkStatus(status));
        }
    };

    private boolean registeredWithService = false;

    McuRadioClient(@NonNull McuClient parent) {
        this.parent = parent;
        parent.addReadyListener(this::onServiceReady);
    }

    private void onServiceReady() {
        registeredWithService = false;
        if (!javaListeners.isEmpty()) ensureRegistered();
    }

    private void ensureRegistered() {
        if (registeredWithService) return;
        IMcuService svc = parent.getServiceUnchecked();
        if (svc == null) return;
        try {
            svc.registerRadioListener(aidlListener);
            registeredWithService = true;
            Log.i(TAG, "registered radio listener with Service");
        } catch (RemoteException e) {
            Log.e(TAG, "registerRadioListener failed", e);
        }
    }

    public void addListener(@NonNull RadioListener l) {
        javaListeners.add(l);
        ensureRegistered();
    }

    public void removeListener(@Nullable RadioListener l) {
        if (l != null) javaListeners.remove(l);
    }

    @Nullable
    public RadioState getLatestState() {
        IMcuService svc = parent.getServiceUnchecked();
        if (svc == null) return null;
        try { return svc.getLatestRadioState(); }
        catch (RemoteException e) { Log.e(TAG, "getLatestRadioState failed", e); return null; }
    }

    public int setBand(int band) {
        return setBand(band, 0);
    }

    public int setBand(int band, int freq) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setBandWithFreq(band, freq); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    public int setFrequency(int freq) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setFrequency(freq); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    public int setFreqRange(int area, int amMin, int amMax,
                            int fmMin, int fmMax, int amStep, int fmStep) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setFreqRange(area, amMin, amMax, fmMin, fmMax, amStep, fmStep); }
        catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    public int seekStop()      { return setSeek(SEEK_STOP,     0); }
    public int seekAms()       { return setSeek(SEEK_AMS,      0); }
    public int seekForward()   { return setSeek(SEEK_FORWARD,  0); }
    public int seekBackward()  { return setSeek(SEEK_BACKWARD, 0); }
    public int seekPty(int pty){ return setSeek(SEEK_PTY,      pty); }
    public int seekTa()        { return setSeek(SEEK_TA,       0); }

    public int setSeek(int mode, int pty) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setSeek(mode, pty); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    /**
     * Radio 0x82 SWITCH_ON_OFF.
     *
     * @param subType 开关类型：{@link #SWITCH_TA}=0, {@link #SWITCH_AF}=1,
     *                {@link #SWITCH_STEREO}=2, {@link #SWITCH_LOCAL}=3,
     *                {@link #SWITCH_RDS}=4, {@link #SWITCH_CT}=5
     * @param value   0=关/关闭/远程，1=开/开启/近程（取决于 subType）
     */
    public int setSwitch(int subType, int value) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setSwitch(subType, value); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    public int setAntPower(boolean on) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setAntPower(on); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    public int setRegMute(boolean mute) {
        IMcuService s = parent.getServiceUnchecked();
        if (s == null) return McuErrorCode.ERR_SERVICE;
        try { return s.setRegMute(mute); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
    }

    private static void safe(Runnable r) {
        try { r.run(); } catch (Throwable t) { Log.w(TAG, "listener threw", t); }
    }
}


Now I have a clear picture. Let me plan the implementation:

1. The `IMcuFrameListener` is already imported in McuClient
2. `McuClient.addFrameListener(IMcuFrameListener)` is available
3. `IMcuFrameListener.onMcuFrame(int frameType, int msgType, int cmd, byte[] body)` gives raw bytes

Now let me plan the full implementation:

### Changes needed in MainActivity.java:

1. **Add new UI fields** for the Frame Monitor card:
   - `tvValid` - valid indicator
   - `tvPty` - PTY display
   - `tvFlagTp`, `tvFlagTa` - traffic flags
   - `tvPs` - PS station name (from onPsPty)
   - `tvRt` - RT radio text (from onRadioText)
   - `tvFreqHex` - raw FREQ frame hex
   - `tvPsPtyHex` - raw PS_PTY frame hex
   - `tvRtHex` - raw RT frame hex
   - `tvWorkStatus` - work status

2. **Update callbacks** to populate UI:
   - `onPsPty` - update tvPs and tvPty
   - `onRadioText` - update tvRt
   - `onWorkStatus` - update tvWorkStatus
   - `onTaPtyTips` - could also be displayed

3. **Register IMcuFrameListener** for raw hex:
   - Filter for msgType == 0x05 (RADIO)
   - Display hex bytes based on cmd (0x01=FREQ, 0x02=PS_PTY, 0x03=RT)

4. **Update updateRadio()** to also show valid, pty, flags

Now about the testapp directory. The user wants:
- APK in `/Users/admin/workspace/jyq/imgs/testapp/`
- Code also there

I'll modify the existing demo module in the project, build it, then copy the APK. For the code, I'll copy the modified MainActivity.java.

Actually wait, I should think about this differently. The user says "测试软件和代码先放置于 /Users/admin/workspace/jyq/imgs/testapp". This means they want the test app and its code in that folder. But the app needs the whole project to build (SDK, contract modules, etc.).

I think the best approach is:
1. Modify the existing `demos/radio-demo` MainActivity.java in the project
2. Build the APK from the project
3. Copy the APK to `/Users/admin/workspace/jyq/imgs/testapp/`
4. Also copy the source code (MainActivity.java and AndroidManifest.xml) to the testapp folder for reference

Let me also check the RadioState.java to see all available fields and the IMcuFrameListener.aidl.


Now let me check the RadioState model and the frame listener interface.


1	package com.qsjnic.mcu.contract;
2
3	import android.os.Parcel;
4	import android.os.Parcelable;
5
6	import androidx.annotation.NonNull;
7
8	/**
9	 * Radio tuner state, parsed from Radio cmd 0x01 FREQ frame (≥7 bytes).
10	 *

11	 *
FM: freq in 100 KHz units (e.g. 8750 = 87.5 MHz)

12	 *
AM: freq in KHz (e.g. 522 = 522 KHz)

13	 *

14	 */
15	public final class RadioState implements Parcelable {
16
17	    /** 0 = FM, 1 = AM. */
18	    public int band = -1;
19
20	    /** Raw frequency value; unit depends on band (see class doc). */
21	    public int freq = -1;
22
23	    /** Signal strength 0..255 (raw). */
24	    public int signal = -1;
25
26	    /** Byte12 bit7 — 调谐锁定标志。1=有效台，0=无效/噪声。 */
27	    public int valid = -1;
28
29	    /** Byte11 bit3-0 — 当前搜索状态。
30	     *  0=停止, 1=AMS, 2=向前, 3=向后, 4=PTY, 5=TA. */
31	    public int seekMode = -1;
32
33	    /** Byte13 — 当前台的节目类型码（PTY），0-31。 */
34	    public int pty = -1;
35
36	    /** Byte12 bit0 — 立体声指示。1=有立体声。 */
37	    public int flagStereo = -1;
38
39	    /** Byte12 bit2 — TA 电台标志。1=是 TA 电台。 */
40	    public int flagTa = -1;
41
42	    /** Byte12 bit1 — TP 显示标志。1=有 TP 显示。 */
43	    public int flagTp = -1;
44
45	    /** Byte11 bit4 — TA 开关状态反馈。1=开。 */
46	    public int switchTa = -1;
47
48	    /** Byte11 bit5 — AF 开关状态反馈。1=开。 */
49	    public int switchAf = -1;
50
51	    /** Byte12 bit3 — AF 切换期间闪烁。1=闪烁中。 */
52	    public int afBlink = -1;
53
54	    /** Byte12 bit4 — 波段切换中标志。1=切换中。 */
55	    public int bandSwitching = -1;
56
57	    public RadioState() {}
58
59	    protected RadioState(Parcel in) {
60	        band          = in.readInt();
61	        freq          = in.readInt();
62	        signal        = in.readInt();
63	        valid         = in.readInt();
64	        seekMode      = in.readInt();
65	        pty           = in.readInt();
66	        flagStereo    = in.readInt();
67	        flagTa        = in.readInt();
68	        flagTp        = in.readInt();
69	        switchTa      = in.readInt();
70	        switchAf      = in.readInt();
71	        afBlink       = in.readInt();
72	        bandSwitching = in.readInt();
73	    }
74
75	    @Override public int describeContents() { return 0; }
76
77	    @Override
78	    public void writeToParcel(@NonNull Parcel dest, int flags) {
79	        dest.writeInt(band);
80	        dest.writeInt(freq);
81	        dest.writeInt(signal);
82	        dest.writeInt(valid);
83	        dest.writeInt(seekMode);
84	        dest.writeInt(pty);
85	        dest.writeInt(flagStereo);
86	        dest.writeInt(flagTa);
87	        dest.writeInt(flagTp);
88	        dest.writeInt(switchTa);
89	        dest.writeInt(switchAf);
90	        dest.writeInt(afBlink);
91	        dest.writeInt(bandSwitching);
92	    }
93
94	    @NonNull
95	    @Override
96	    public String toString() {
97	        String unit = (band == 0) ? "(*100KHz)" : "KHz";
98	        String b    = (band == 0) ? "FM" : (band == 1 ? "AM" : "?");
99	        return "RadioState{" + b + " " + freq + unit
100	                + " signal=" + signal
101	                + " valid=" + valid
102	                + " seekMode=" + seekMode
103	                + " pty=" + pty
104	                + " st=" + flagStereo
105	                + " ta=" + flagTa
106	                + " tp=" + flagTp
107	                + " swTA=" + switchTa
108	                + " swAF=" + switchAf
109	                + " afBlink=" + afBlink
110	                + " bandSwitching=" + bandSwitching
111	                + '}';
112	    }
113
114	    public static final Creator<RadioState> CREATOR = new Creator<RadioState>() {
115	        @Override public RadioState createFromParcel(Parcel in) { return new RadioState(in); }
116	        @Override public RadioState[] newArray(int size) { return new RadioState[size]; }
117	    };
118	}
119

package com.qsjnic.mcu.contract;

import android.os.Parcel;
import android.os.Parcelable;

import androidx.annotation.NonNull;

/**
 * Radio tuner state, parsed from Radio cmd 0x01 FREQ frame (≥7 bytes).
 *

 *
FM: freq in 100 KHz units (e.g. 8750 = 87.5 MHz)

 *
AM: freq in KHz (e.g. 522 = 522 KHz)

 *

 */
public final class RadioState implements Parcelable {

    /** 0 = FM, 1 = AM. */
    public int band = -1;

    /** Raw frequency value; unit depends on band (see class doc). */
    public int freq = -1;

    /** Signal strength 0..255 (raw). */
    public int signal = -1;

    /** Byte12 bit7 — 调谐锁定标志。1=有效台，0=无效/噪声。 */
    public int valid = -1;

    /** Byte11 bit3-0 — 当前搜索状态。
     *  0=停止, 1=AMS, 2=向前, 3=向后, 4=PTY, 5=TA. */
    public int seekMode = -1;

    /** Byte13 — 当前台的节目类型码（PTY），0-31。 */
    public int pty = -1;

    /** Byte12 bit0 — 立体声指示。1=有立体声。 */
    public int flagStereo = -1;

    /** Byte12 bit2 — TA 电台标志。1=是 TA 电台。 */
    public int flagTa = -1;

    /** Byte12 bit1 — TP 显示标志。1=有 TP 显示。 */
    public int flagTp = -1;

    /** Byte11 bit4 — TA 开关状态反馈。1=开。 */
    public int switchTa = -1;

    /** Byte11 bit5 — AF 开关状态反馈。1=开。 */
    public int switchAf = -1;

    /** Byte12 bit3 — AF 切换期间闪烁。1=闪烁中。 */
    public int afBlink = -1;

    /** Byte12 bit4 — 波段切换中标志。1=切换中。 */
    public int bandSwitching = -1;

    public RadioState() {}

    protected RadioState(Parcel in) {
        band          = in.readInt();
        freq          = in.readInt();
        signal        = in.readInt();
        valid         = in.readInt();
        seekMode      = in.readInt();
        pty           = in.readInt();
        flagStereo    = in.readInt();
        flagTa        = in.readInt();
        flagTp        = in.readInt();
        switchTa      = in.readInt();
        switchAf      = in.readInt();
        afBlink       = in.readInt();
        bandSwitching = in.readInt();
    }

    @Override public int describeContents() { return 0; }

    @Override
    public void writeToParcel(@NonNull Parcel dest, int flags) {
        dest.writeInt(band);
        dest.writeInt(freq);
        dest.writeInt(signal);
        dest.writeInt(valid);
        dest.writeInt(seekMode);
        dest.writeInt(pty);
        dest.writeInt(flagStereo);
        dest.writeInt(flagTa);
        dest.writeInt(flagTp);
        dest.writeInt(switchTa);
        dest.writeInt(switchAf);
        dest.writeInt(afBlink);
        dest.writeInt(bandSwitching);
    }

    @NonNull
    @Override
    public String toString() {
        String unit = (band == 0) ? "(*100KHz)" : "KHz";
        String b    = (band == 0) ? "FM" : (band == 1 ? "AM" : "?");
        return "RadioState{" + b + " " + freq + unit
                + " signal=" + signal
                + " valid=" + valid
                + " seekMode=" + seekMode
                + " pty=" + pty
                + " st=" + flagStereo
                + " ta=" + flagTa
                + " tp=" + flagTp
                + " swTA=" + switchTa
                + " swAF=" + switchAf
                + " afBlink=" + afBlink
                + " bandSwitching=" + bandSwitching
                + '}';
    }

    public static final Creator<RadioState> CREATOR = new Creator<RadioState>() {
        @Override public RadioState createFromParcel(Parcel in) { return new RadioState(in); }
        @Override public RadioState[] newArray(int size) { return new RadioState[size]; }
    };
}


1	package com.qsjnic.mcu.contract;
2
3	import com.qsjnic.mcu.contract.VehicleSnapshot;
4	import com.qsjnic.mcu.contract.RadioState;
5	import com.qsjnic.mcu.contract.UpgradeProgress;
6
7	interface IMcuFrameListener {
8	    /** Raw frame from MCU. body 已剥 (msgType,cmd) 前 2 字节。 */
9	    oneway void onMcuFrame(int frameType, int msgType, int cmd, in byte[] body);
10
11	    /** 解码后的高层状态变化(decoded)。 */
12	    oneway void onVehicleStateChanged(in VehicleSnapshot snapshot);
13	    oneway void onRadioStateChanged(in RadioState state);
14	    oneway void onUpgradeProgress(in UpgradeProgress progress);
15	}
16

package com.qsjnic.mcu.contract;

import com.qsjnic.mcu.contract.VehicleSnapshot;
import com.qsjnic.mcu.contract.RadioState;
import com.qsjnic.mcu.contract.UpgradeProgress;

interface IMcuFrameListener {
    /** Raw frame from MCU. body 已剥 (msgType,cmd) 前 2 字节。 */
    oneway void onMcuFrame(int frameType, int msgType, int cmd, in byte[] body);

    /** 解码后的高层状态变化(decoded)。 */
    oneway void onVehicleStateChanged(in VehicleSnapshot snapshot);
    oneway void onRadioStateChanged(in RadioState state);
    oneway void onUpgradeProgress(in UpgradeProgress progress);
}


Error: File does not exist. Note: your current working directory is /Users/admin/workspace/jyq/qsjnic. Did you mean build?


The build.gradle doesn't exist at the expected path, let me check the demo's build structure. But actually, the existing demo already builds since the APK exists at `/Users/admin/workspace/jyq/qsjnic/release/v1.0.0-beta6/qsmw-radio-demo.apk`. I just need to modify the MainActivity and rebuild.

Let me check the build structure.

Actually, let me just focus on the implementation now. I have all the information I need:

1. `RadioState` has these fields I want to display additionally: `valid`, `pty`, `flagTp`, `flagTa`, `switchTa`, `switchAf`, `afBlink`, `bandSwitching`
2. `IMcuFrameListener.onMcuFrame(frameType, msgType, cmd, body)` gives raw bytes
3. `McuClient.addFrameListener(IMcuFrameListener)` for registration
4. `MsgType.RADIO = 0x05`

Now let me implement the plan. I'll create task items and then write the code.

The key design decisions:
1. New "Frame Monitor" card between frequency card and audio section
2. Show parsed values: valid, PTY, PS, RT, work status, flags
3. Show raw hex for FREQ/PS_PTY/RT frames
4. Color coding: valid=1 green, valid=0 red

Let me write the implementation plan and then code it.

For the raw frame listener, I need to:
- Create an IMcuFrameListener.Stub
- In onMcuFrame, filter for msgType == 0x05 (RADIO)
- Based on cmd: 0x01 = FREQ, 0x02 = PS_PTY, 0x03 = RT, 0x04 = TA_PTY_TIPS, 0x05 = WORK_STATUS
- Convert body bytes to hex string and display

Let me write the code now.

For the UI layout, I'll add a new card after the frequency card:

```
┌─ Frame Monitor ───────────────────────┐
│  valid: ● LOCKED (1)    PTY: 5        │
│  TA: OFF   TP: OFF   AF: OFF          │
│  Work Status: 0 (OK)                  │
│  PS: [CBC MUSIC]                      │
│  RT: [Now Playing...]                 │
│  ─────────────────────                │
│  FREQ hex: 26 D8 00 40 00 00 00       │
│  PS/PTY hex: 43 42 43 20 4D...        │
│  RT hex: 4E 6F 77 20 50...            │
└───────────────────────────────────────┘
```

Now let me think about the hex format. The body bytes from IMcuFrameListener already have msg_type and cmd stripped (per FrameRouter.onMcuFrame). So for FREQ frame, body should be 7 bytes. For PS_PTY, 9 bytes. For RT, 64 bytes.

For RT (64 bytes), I should truncate the hex display to avoid taking too much space.

Let me think about the exact layout structure. I'll use LinearLayout with vertical orientation and build it programmatically, consistent with the existing code style.

For the hex display, I'll use a monospace typeface to make hex readable.

Now, about the testapp folder - I'll:
1. Modify MainActivity.java in the project
2. Build the APK
3. Copy APK → /Users/admin/workspace/jyq/imgs/testapp/qsmw-radio-demo.apk
4. Copy source → /Users/admin/workspace/jyq/imgs/testapp/MainActivity.java

Let me also think about listing the dependencies and documenting what was changed for the user.

Actually, I realize I should also add `onTaPtyTips` display since the feedback mentions it, and it's part of the complete radio monitor picture.

Let me now create tasks and start implementing.


Now I have all the details. Let me create tasks and implement the changes.


TaskCreate

Add Frame Monitor card to MainActivity with valid/PS/RT/PTY display

Add a new "Frame Monitor" debug card between the frequency card and audio section, showing: valid bit (color-coded), PTY, traffic flags (TA/TP), PS station name, RT radio text, work status, TA/PTY tips, and raw hex dump of FREQ/PS_PTY/RT frames. Also update onPsPty/onRadioText/onWorkStatus/onTaPtyTips callbacks to populate UI, and register an IMcuFrameListener for raw hex.


Task #1 created successfully: Add Frame Monitor card to MainActivity with valid/PS/RT/PTY display


Let me also create tasks for building and copying the APK.


Build the radio-demo APK

Build the modified radio-demo APK from the project using gradle.


Task #2 created successfully: Build the radio-demo APK

Build the radio-demo APK


Copy APK and source code to /Users/admin/workspace/jyq/imgs/testapp/

Copy the built APK and the modified source files to the testapp folder.


Task #3 created successfully: Copy APK and source code to /Users/admin/workspace/jyq/imgs/testapp/


Now let me start implementing Task #1. I need to modify MainActivity.java to add:

1. New UI fields
2. Frame Monitor card
3. Updated callbacks
4. IMcuFrameListener for raw hex

Let me write the complete updated MainActivity.java. I need to be careful to preserve all existing functionality.

Let me plan the exact changes:

### New fields to add:
```java
// Frame Monitor fields
private TextView tvValid, tvPty, tvTa, tvTp, tvPs, tvRt, tvWorkStatus, tvTaPtyTips;
private TextView tvFreqHex, tvPsPtyHex, tvRtHex;
```

### New card after frequency card:
Add a "Frame Monitor" section with all the debug info.

### Updated callbacks:
- onPsPty: update tvPs, tvPty
- onRadioText: update tvRt
- onWorkStatus: update tvWorkStatus
- onTaPtyTips: update tvTaPtyTips

### Raw frame listener:
Register IMcuFrameListener to capture raw body bytes and display hex.

Let me now write the code changes. I'll use Edit operations to make precise changes.

Actually, given the scope of changes, it might be cleaner to rewrite the entire file. But let me use Edit operations to be precise.

Let me plan each edit:

1. Add new TextView fields after existing fields
2. Add the Frame Monitor card UI in onCreate (after freqCard)
3. Update listener callbacks
4. Add raw frame listener registration
5. Update updateRadio to also show valid, pty, flags

Let me write the code.

For the hex display, I need a helper to convert byte[] to hex string:
```java
private static String bytesToHex(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        sb.append(String.format("%02X ", b & 0xFF));
    }
    return sb.toString().trim();
}
```

For the UI card, I'll follow the existing pattern with card(), sectionTitle(), chip() etc.

Let me think about the layout:

```
sectionTitle("Frame Monitor")
card:
  Row 1: [valid chip] [pty] [TA chip] [TP chip]
  Row 2: tvWorkStatus
  Row 3: tvPs ("PS: ...")
  Row 4: tvRt ("RT: ...")  (maybe smaller text)
  Row 5: tvTaPtyTips
  divider
  Row 6: tvFreqHex (monospace)
  Row 7: tvPsPtyHex (monospace)
  Row 8: tvRtHex (monospace, truncated)
```

Actually, to keep it clean and compact, let me organize it differently. Let me make:
- A row of status chips: valid, pty, TA, TP
- Text rows for PS, RT
- Work status
- Hex dump section with label

Let me code this now.

For the raw frame listener, I need to import IMcuFrameListener:
```java
import com.qsjnic.mcu.contract.IMcuFrameListener;
```

And I need to track the MsgType. Looking at the code, MsgType is in the contract module. Looking at the existing imports in MainActivity, I see `com.qsjnic.mcu.contract.RadioState`. I also need `com.qsjnic.mcu.domain.MsgType` for MSG_TYPE_RADIO = 0x05. But to keep it simple, I can just hardcode 0x05 or import MsgType.

Let me check if MsgType is importable...

Actually, looking at the imports, the demo only imports from `com.qsjnic.mcu.contract` and `com.qsjnic.mcu.sdk`. MsgType is in `com.qsjnic.mcu.domain`. Let me just use the constant directly (0x05) or import it.

Actually wait, I should check what package MsgType is in...

From the exploration, MsgType.java is at: `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/MsgType.java`

So it's in the contract module, package `com.qsjnic.mcu.domain`. I can import it.

Actually, let me keep it simple and just use the hex value 0x05 directly, or better yet, import MsgType. Let me check what's in MsgType.

Actually, I don't need to read MsgType - I already know RADIO = 0x05. Let me just use the constant or a local constant.

Let me now write all the edits. I'll be systematic.

Actually, given the extent of changes (many new fields, a big new UI block, updated callbacks), it might be more reliable to rewrite the entire file. But let me try with targeted edits first.

Here's my plan for edits:

**Edit 1:** Add new import for IMcuFrameListener
**Edit 2:** Add new TextView field declarations
**Edit 3:** Add member fields for PS and RT strings (to persist across callbacks)
**Edit 4:** Add Frame Monitor card UI after freqCard (after the detailRow section)
**Edit 5:** Update listener callbacks (onPsPty, onRadioText, onWorkStatus, onTaPtyTips)
**Edit 6:** Register raw frame listener in onCreate
**Edit 7:** Update updateRadio to also update valid, pty, flags
**Edit 8:** Add bytesToHex helper method

Let me think about what the hex display should look like. The body bytes from onMcuFrame already have msgType and cmd stripped. So:
- cmd 0x01 (FREQ): 7 bytes
- cmd 0x02 (PS_PTY): 9 bytes
- cmd 0x03 (RT): 64 bytes
- cmd 0x04 (TA_PTY_TIPS): variable
- cmd 0x05 (WORK_STATUS): variable

For the RT frame (64 bytes), I should display it compactly. Maybe show first 32 bytes + "..." or just show it all in a smaller font.

OK let me write the code. I'll structure it as edits.

Actually, for the raw frame listener, I need to be careful. The existing `frameListenerStub` in McuClient has empty method bodies. But the demo can register its own separate listener.

Looking at the McuClient code:
```java
public void addFrameListener(IMcuFrameListener cb) {
    IMcuService s = getServiceUnchecked();
    if (s == null) return;
    try { s.registerFrameListener(cb); } catch (RemoteException ignored) {}
}
```

So I can create my own IMcuFrameListener.Stub and register it. But I need to be careful — `addFrameListener` only works when service is ready. I should register it in the `addReadyListener` callback.

Also, I need to remove it in onDestroy.

Let me now write all edits. Let me start coding.

One more consideration: the `IMcuFrameListener.Stub` needs to be imported. The AIDL generates `IMcuFrameListener` in the `com.qsjnic.mcu.contract` package.

OK, let me now write the code. I'll create the complete set of edits.

For the UI, let me think about the exact structure. I want:

```
┌─ Frame Monitor ───────────────────────────────┐
│  [valid: ● LOCKED] [PTY: 5] [TA: OFF] [TP: OFF]  ← row of chips
│  Work: 0 (OK)                                   ← simple text
│  PS: "CBC MUSIC"                                ← labeled text
│  RT: "Now Playing - Song Title"                 ← labeled text (scrollable?)
│  ─────────────────────────────────────          ← thin divider
│  FREQ (7B):  26 D8 00 40 00 00 00              ← monospace hex
│  PS/PTY (9B): 00 00 00 00 00 00 00 00 00      ← monospace hex
│  RT (64B):   00 00 00 ... (first 32 bytes)     ← monospace hex, truncated
└──────────────────────────────────────────────────┘
```

Wait, but the current code has "chip" style for tvSignal, tvStereo, tvSeek. Let me match that style for valid, pty, etc.

Actually, looking at this more carefully, I want to make the hex display optional or collapsible since it takes a lot of space. But for simplicity (and since this is a debug tool), let me just show it directly.

For the RT hex (64 bytes), that's a lot of text even in hex (64*3=192 chars). Let me show it in a smaller text size or on multiple lines. Actually, for debugging purposes, it's important to see if they're all zeros or not, so even showing the first 16 bytes would be sufficient, but let me show all for completeness. I'll use a smaller text size for hex lines.

Let me also think about word wrapping for the RT hex. Since it's 64 bytes = 192 chars of hex, it might need to wrap. I could use a smaller font (10sp) and let it wrap naturally.

Let me now code this.

Actually I realize I need to think about one more thing: the `addFrameListener` in McuClient doesn't have a re-registration mechanism on reconnect like the radio listener does. Let me check...

Looking at McuClient:
```java
public void addFrameListener(IMcuFrameListener cb) {
    IMcuService s = getServiceUnchecked();
    if (s == null) return;
    try { s.registerFrameListener(cb); } catch (RemoteException ignored) {}
}
```

And in onServiceConnected:
```java
if (frameListenerRegistered) {
    try { svc.registerFrameListener(frameListenerStub); }
    catch (RemoteException e) { Log.w(TAG, "re-register frame listener failed", e); }
}
```

There's a `frameListenerRegistered` flag but it's tied to the internal `frameListenerStub`. So external frame listeners added via `addFrameListener` won't be automatically re-registered. I need to handle this in the demo by registering in the ready listener.

Actually wait, let me look again. The `addFrameListener` method just directly registers with the service. It doesn't track the listener for re-registration. So if the service disconnects and reconnects, the demo's frame listener won't be re-registered.

I should handle this by:
1. Registering in the ready listener (which fires on initial connect and reconnects)
2. Or also calling addFrameListener periodically

Actually, looking more carefully at the code flow:
- `init()` → `bind()` → `onServiceConnected()` → fires `readyListeners`
- If service dies, `binderDied()` → `bind()` again → `onServiceConnected()` → fires `readyListeners` again

So I can register the frame listener in the `addReadyListener` callback. But wait, `addReadyListener` fires immediately if already ready. And re-fires on reconnect. That's exactly what I need.

Let me structure it as:
```java
McuClient.getInstance().addReadyListener(() -> {
    // ... existing code ...
    McuClient.getInstance().addFrameListener(rawFrameListener);
});
```

This ensures it's registered on initial connect and on reconnect.

Now let me also think about thread safety. The `onMcuFrame` callback comes from the FrameRouter handler thread, so I need to post to the UI thread. I'll use `ui.post()` like the existing code does.

OK, I have enough context. Let me write the code now.

Let me write the complete file, since there are many changes across the file. Actually, let me use targeted edits to minimize risk.

Let me plan the edits more carefully:

**Edit 1:** Add import for IMcuFrameListener
```java
import com.qsjnic.mcu.contract.IMcuFrameListener;
```
Insert after the RadioState import.

**Edit 2:** Add new field declarations
```java
    // Frame Monitor fields
    private TextView tvValid, tvPty, tvTa, tvTp, tvPs, tvRt, tvWorkStatus, tvTaPtyTips;
    private TextView tvFreqHex, tvPsPtyHex, tvRtHex;
    private String lastPs = "";
    private String lastRt = "";
```
Insert after the existing tvSeek declaration.

**Edit 3:** Add Frame Monitor UI card
Insert after the frequency card (after the detailRow section, before the Audio section).

**Edit 4:** Update listener callbacks
Replace onPsPty, onRadioText, onWorkStatus with versions that update UI.

**Edit 5:** Add raw frame listener and bytesToHex

**Edit 6:** Update updateRadio() to show valid, pty, flags

Let me now write each edit precisely.

Actually, this is getting complex with many edits. Let me just rewrite the file. That's cleaner and less error-prone.

Wait, the instructions say to prefer editing existing files. Let me try with edits. But if there are too many, I'll rewrite.

Let me count the edits:
1. Import line
2. Field declarations
3. Listener callback updates (4 callbacks)
4. UI card insertion (big block)
5. updateRadio additions
6. Raw frame listener
7. Helper method
8. onDestroy cleanup

That's at least 8 edits. Some are large blocks. Let me use a few strategic edits.

Actually, let me just write the complete file. It's more reliable for a change of this magnitude, and the file is only 443 lines.

Let me write the new file now.


Updated task #1 status


Let me now write the complete updated MainActivity.java. I'll be careful to preserve all existing functionality while adding the new Frame Monitor card.

Let me draft the entire file:


Now I'll implement the changes. The modifications to `MainActivity.java` cover:

- **New fields**: `tvValid`, `tvPty`, `tvTa`, `tvTp`, `tvPs`, `tvRt`, `tvWorkStatus`, `tvTaPtyTips` for parsed values
- **New fields**: `tvFreqHex`, `tvPsPtyHex`, `tvRtHex` for raw hex dump
- **IMcuFrameListener**: captures raw body bytes for hex display
- **Updated callbacks**: `onPsPty`, `onRadioText`, `onWorkStatus`, `onTaPtyTips` now update UI
- **"Frame Monitor" card**: inserted between frequency card and audio section


Write

package com.qsjnic.demo.radio;

import android.app.Activity;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;

import com.qsjnic.mcu.contract.IMcuFrameListener;
import com.qsjnic.mcu.contract.RadioState;
import com.qsjnic.mcu.sdk.McuClient;
import com.qsjnic.mcu.sdk.McuRadioClient;
import com.qsjnic.mcu.sdk.RadioListener;

/**
 * Radio Demo with FM Tuner audio passthrough and MCU frame monitor.
 */
public class MainActivity extends Activity {

    private static final String TAG = "RadioDemo";
    private static final int PERMISSION_REQ = 100;

    private final Handler ui = new Handler(Looper.getMainLooper());

    // Data panel
    private TextView tvBand, tvFreq, tvFreqMhz, tvSignal, tvStereo, tvSeek, tvAudioStatus;

    // Frame Monitor fields
    private TextView tvValid, tvPty, tvTa, tvTp, tvPs, tvRt, tvWorkStatus, tvTaPtyTips;
    private TextView tvFreqHex, tvPsPtyHex, tvRtHex;
    private String lastPs = "";
    private String lastRt = "";

    // Control buttons
    private TextView tvStatus;
    private Button btnFm, btnAm, btn875, btn900, btnSeekFw, btnSeekStop, btnPlay875;
    private Button btnAudioStart, btnAudioStop;

    private final RadioListener listener = new RadioListener() {
        @Override public void onFreqChanged(RadioState s) {
            ui.post(() -> updateRadio(s));
        }
        @Override public void onPsPty(int pi, int pty, String ps) {
            Log.i(TAG, "PS/PTY pi=" + pi + " pty=" + pty + " ps=" + ps);
            ui.post(() -> {
                lastPs = (ps != null) ? ps : "";
                tvPs.setText("PS: " + (lastPs.isEmpty() ? "(empty)" : lastPs));
                tvPs.setTextColor(lastPs.isEmpty() ? Color.parseColor("#F44336") : Color.parseColor("#4CAF50"));
                tvPty.setText("PTY: " + pty);
            });
        }
        @Override public void onRadioText(String rt) {
            Log.i(TAG, "RT=" + rt);
            ui.post(() -> {
                lastRt = (rt != null) ? rt : "";
                // Truncate display if too long
                String display = lastRt.isEmpty() ? "(empty)" : (lastRt.length() > 80 ? lastRt.substring(0, 80) + "..." : lastRt);
                tvRt.setText("RT: " + display);
                tvRt.setTextColor(lastRt.isEmpty() ? Color.parseColor("#F44336") : Color.parseColor("#4CAF50"));
            });
        }
        @Override public void onWorkStatus(int status) {
            Log.i(TAG, "WorkStatus=" + status);
            ui.post(() -> {
                tvWorkStatus.setText("Work: " + status + (status == 0 ? " (OK)" : " (ERR)"));
                tvWorkStatus.setTextColor(status == 0 ? Color.parseColor("#4CAF50") : Color.parseColor("#F44336"));
            });
        }
        @Override public void onTaPtyTips(int mode, int value) {
            Log.i(TAG, "TA/PTY Tips mode=" + mode + " value=" + value);
            ui.post(() -> {
                String modeName = mode == 0 ? "TA" : (mode == 1 ? "PTY" : "?");
                String valName = value == 0 ? "Clear" : (value == 1 ? "NoResult" : (value == 2 ? "Searching" : "?"));
                tvTaPtyTips.setText("Tips: " + modeName + " / " + valName);
            });
        }
    };

    // ----- raw frame listener for hex dump -----
    private final IMcuFrameListener.Stub rawFrameListener = new IMcuFrameListener.Stub() {
        @Override
        public void onMcuFrame(int frameType, int msgType, int cmd, byte[] body) {
            // Only monitor RADIO module (0x05)
            if (msgType != 0x05) return;
            String hex = bytesToHex(body);
            ui.post(() -> {
                switch (cmd) {
                    case 0x01: // FREQ
                        tvFreqHex.setText("FREQ (" + body.length + "B): " + hex);
                        break;
                    case 0x02: // PS_PTY
                        tvPsPtyHex.setText("PS/PTY (" + body.length + "B): " + hex);
                        break;
                    case 0x03: // RT
                        String rtHex = body.length > 32
                                ? bytesToHex(body, 32) + " ..." // truncate display
                                : hex;
                        tvRtHex.setText("RT (" + body.length + "B): " + rtHex);
                        break;
                    case 0x04: // TA_PTY_TIPS
                        break;
                    case 0x05: // WORK_STATUS
                        break;
                }
            });
        }
        @Override public void onVehicleStateChanged(com.qsjnic.mcu.contract.VehicleSnapshot s) {}
        @Override public void onRadioStateChanged(RadioState s) {}
        @Override public void onUpgradeProgress(com.qsjnic.mcu.contract.UpgradeProgress p) {}
    };

    // ----- Audio passthrough -----
    private AudioRecord audioRecord;
    private AudioTrack audioTrack;
    private Thread audioThread;
    private volatile boolean audioRunning = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ScrollView scroll = new ScrollView(this);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setPadding(32, 32, 32, 32);
        root.setBackgroundColor(Color.parseColor("#F5F5F5"));

        // ===== Title =====
        TextView title = new TextView(this);
        title.setText("Radio Tuner + Audio");
        title.setTextSize(24f);
        title.setTypeface(null, Typeface.BOLD);
        title.setTextColor(Color.parseColor("#333333"));
        title.setPadding(0, 0, 0, 16);
        root.addView(title);

        tvStatus = new TextView(this);
        tvStatus.setText("Service: connecting");
        tvStatus.setTextSize(14f);
        tvStatus.setTextColor(Color.parseColor("#555555"));
        tvStatus.setPadding(0, 0, 0, 16);
        root.addView(tvStatus);

        // ===== Frequency Display Card =====
        LinearLayout freqCard = card(root);
        tvBand = new TextView(this);
        tvBand.setText("--");
        tvBand.setTextSize(16f);
        tvBand.setTextColor(Color.parseColor("#888888"));
        tvBand.setGravity(Gravity.CENTER);
        freqCard.addView(tvBand);

        tvFreq = new TextView(this);
        tvFreq.setText("----");
        tvFreq.setTextSize(56f);
        tvFreq.setTypeface(null, Typeface.BOLD);
        tvFreq.setTextColor(Color.parseColor("#1976D2"));
        tvFreq.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreq);

        tvFreqMhz = new TextView(this);
        tvFreqMhz.setText("MHz");
        tvFreqMhz.setTextSize(14f);
        tvFreqMhz.setTextColor(Color.parseColor("#888888"));
        tvFreqMhz.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreqMhz);

        LinearLayout detailRow = new LinearLayout(this);
        detailRow.setOrientation(LinearLayout.HORIZONTAL);
        detailRow.setGravity(Gravity.CENTER);
        detailRow.setPadding(0, 16, 0, 0);
        freqCard.addView(detailRow);

        tvSignal = chip(detailRow, "Signal: --");
        tvStereo = chip(detailRow, "Stereo: --");
        tvSeek   = chip(detailRow, "Seek: --");

        // ===== Frame Monitor Card =====
        root.addView(sectionTitle("Frame Monitor"));
        LinearLayout monCard = card(root);

        // Row 1: valid + pty + TA + TP flags
        LinearLayout monRow1 = new LinearLayout(this);
        monRow1.setOrientation(LinearLayout.HORIZONTAL);
        monRow1.setGravity(Gravity.CENTER_VERTICAL);
        monRow1.setPadding(0, 0, 0, 8);
        monCard.addView(monRow1);

        tvValid = chip(monRow1, "valid: --");
        tvPty   = chip(monRow1, "PTY: --");
        tvTa    = chip(monRow1, "TA: --");
        tvTp    = chip(monRow1, "TP: --");

        // Row 2: work status + TA/PTY tips
        LinearLayout monRow2 = new LinearLayout(this);
        monRow2.setOrientation(LinearLayout.HORIZONTAL);
        monRow2.setGravity(Gravity.CENTER_VERTICAL);
        monRow2.setPadding(0, 0, 0, 4);
        monCard.addView(monRow2);

        tvWorkStatus = monText(monRow2, "Work: --");
        tvTaPtyTips  = monText(monRow2, "Tips: --");

        // Row 3: PS station name
        tvPs = monText(monCard, "PS: --");
        tvPs.setPadding(0, 4, 0, 2);

        // Row 4: RT radio text
        tvRt = monText(monCard, "RT: --");
        tvRt.setPadding(0, 2, 0, 8);

        // Divider
        View divider = new View(this);
        divider.setBackgroundColor(Color.parseColor("#E0E0E0"));
        LinearLayout.LayoutParams dlp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, 1);
        dlp.setMargins(0, 0, 0, 8);
        divider.setLayoutParams(dlp);
        monCard.addView(divider);

        // Hex dump section title
        TextView hexLabel = new TextView(this);
        hexLabel.setText("Raw Frame Hex");
        hexLabel.setTextSize(12f);
        hexLabel.setTypeface(null, Typeface.BOLD);
        hexLabel.setTextColor(Color.parseColor("#888888"));
        hexLabel.setPadding(0, 0, 0, 4);
        monCard.addView(hexLabel);

        // Hex rows (monospace, small)
        tvFreqHex = hexText(monCard);
        tvPsPtyHex = hexText(monCard);
        tvRtHex = hexText(monCard);

        // ===== Audio Status =====
        root.addView(sectionTitle("Tuner Audio"));
        LinearLayout audioCard = card(root);
        tvAudioStatus = new TextView(this);
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextSize(14f);
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        tvAudioStatus.setPadding(0, 8, 0, 8);
        audioCard.addView(tvAudioStatus);

        LinearLayout audioRow = buttonRow(audioCard);
        btnAudioStart = btn(audioRow, "Start Audio", v -> startTunerAudio());
        btnAudioStop  = btn(audioRow, "Stop Audio",  v -> stopTunerAudio());

        // ===== Band Selection =====
        root.addView(sectionTitle("Band"));
        LinearLayout bandCard = card(root);
        LinearLayout bandRow = buttonRow(bandCard);
        btnFm = btn(bandRow, "FM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_FM));
        btnAm = btn(bandRow, "AM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_AM));

        // ===== Frequency Presets =====
        root.addView(sectionTitle("Frequency"));
        LinearLayout freqCtrlCard = card(root);
        LinearLayout freqRow1 = buttonRow(freqCtrlCard);
        btn875 = btn(freqRow1, "87.5 MHz", v -> McuClient.getInstance().radio().setFrequency(8750));
        btn900 = btn(freqRow1, "90.0 MHz", v -> McuClient.getInstance().radio().setFrequency(9000));

        // ===== Seek Control =====
        root.addView(sectionTitle("Seek"));
        LinearLayout seekCard = card(root);
        LinearLayout seekRow = buttonRow(seekCard);
        btnSeekFw   = btn(seekRow, "Forward",  v -> McuClient.getInstance().radio().seekForward());
        btnSeekStop = btn(seekRow, "Stop",     v -> McuClient.getInstance().radio().seekStop());

        // ===== Other =====
        root.addView(sectionTitle("Other"));
        LinearLayout otherCard = card(root);
        LinearLayout otherRow = buttonRow(otherCard);
        btnPlay875 = btn(otherRow, "Play 90.0", v -> playFm900());
        btn(otherRow, "Ant ON",  v -> McuClient.getInstance().radio().setAntPower(true));
        btn(otherRow, "Init Range", v -> McuClient.getInstance().radio().setFreqRange(0, 522, 1670, 8750, 10800, 9, 1));

        scroll.addView(root);
        setContentView(scroll);

        McuClient.getInstance().init(getApplicationContext());
        McuClient.getInstance().addReadyListener(() -> ui.post(() -> {
            tvStatus.setText("Service: connected, linkAlive=" + McuClient.getInstance().isLinkAlive());
            playFm900();
            ui.postDelayed(this::startTunerAudio, 1000);
            // Register raw frame listener for hex dump
            McuClient.getInstance().addFrameListener(rawFrameListener);
        }));
        McuClient.getInstance().radio().addListener(listener);

        // Request audio permission
        if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(new String[]{android.Manifest.permission.RECORD_AUDIO}, PERMISSION_REQ);
        }
    }

    // ---------- Audio Passthrough ----------

    private void startTunerAudio() {
        if (audioRunning) return;

        AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
        AudioDeviceInfo tunerDevice = null;
        for (AudioDeviceInfo dev : am.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
            Log.i(TAG, "Input device: type=" + dev.getType() + " name=" + dev.getProductName());
            if (dev.getType() == AudioDeviceInfo.TYPE_FM_TUNER) {
                tunerDevice = dev;
                break;
            }
        }

        if (tunerDevice == null) {
            tvAudioStatus.setText("Audio: FM Tuner device NOT FOUND");
            Log.e(TAG, "TYPE_FM_TUNER not found in input devices");
            return;
        }

        tvAudioStatus.setText("Audio: starting...");
        Log.i(TAG, "Found FM Tuner: " + tunerDevice.getProductName());

        final int sampleRate = 48000;
        final int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
        final int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
        final int minBufSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
        final int bufferSize = Math.max(minBufSize, 16384);
        // AUDIO_SOURCE_FM_TUNER = 1998 (from system/audio.h)
        final int AUDIO_SOURCE_FM_TUNER = 1998;

        try {
            // Use DEFAULT source + setPreferredDevice(tuner) approach
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
                    sampleRate, channelConfig, audioFormat, bufferSize);

            // Try to set device on API 24+
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && audioRecord.setPreferredDevice(tunerDevice)) {
                Log.i(TAG, "setPreferredDevice(tuner) succeeded");
            }

            audioTrack = new AudioTrack.Builder()
                    .setAudioAttributes(new AudioAttributes.Builder()
                            .setUsage(AudioAttributes.USAGE_MEDIA)
                            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                            .build())
                    .setAudioFormat(new AudioFormat.Builder()
                            .setSampleRate(sampleRate)
                            .setEncoding(audioFormat)
                            .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                            .build())
                    .setBufferSizeInBytes(bufferSize)
                    .setTransferMode(AudioTrack.MODE_STREAM)
                    .build();

            audioRecord.startRecording();
            audioTrack.play();
            audioRunning = true;

            final int playbackBufSize = bufferSize;
            audioThread = new Thread(() -> {
                android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
                byte[] buf = new byte[playbackBufSize];
                int totalRead = 0;
                long lastLog = System.currentTimeMillis();
                while (audioRunning) {
                    int read = audioRecord.read(buf, 0, buf.length);
                    if (read > 0) {
                        totalRead += read;
                        audioTrack.write(buf, 0, read);
                        // Log first non-zero sample every 3 seconds
                        long now = System.currentTimeMillis();
                        if (now - lastLog > 3000) {
                            short sample = (short) ((buf[0] & 0xFF) | ((buf[1] & 0xFF) << 8));
                            Log.i(TAG, "Audio passthrough: read=" + read
                                    + " total=" + totalRead
                                    + " sample=" + sample
                                    + " trackState=" + audioTrack.getPlayState());
                            lastLog = now;
                        }
                    } else if (read < 0) {
                        Log.e(TAG, "AudioRecord read error: " + read);
                        break;
                    }
                }
            }, "TunerAudio");
            audioThread.setPriority(Thread.MAX_PRIORITY);
            audioThread.start();

            tvAudioStatus.setText("Audio: PLAYING (" + tunerDevice.getProductName() + ")");
            tvAudioStatus.setTextColor(Color.parseColor("#4CAF50"));

        } catch (Exception e) {
            Log.e(TAG, "startTunerAudio failed", e);
            tvAudioStatus.setText("Audio: FAILED - " + e.getMessage());
            tvAudioStatus.setTextColor(Color.parseColor("#F44336"));
            stopTunerAudio();
        }
    }

    private void stopTunerAudio() {
        audioRunning = false;
        if (audioThread != null) {
            try { audioThread.join(500); } catch (InterruptedException ignored) {}
            audioThread = null;
        }
        if (audioRecord != null) {
            try { audioRecord.stop(); } catch (Exception ignored) {}
            try { audioRecord.release(); } catch (Exception ignored) {}
            audioRecord = null;
        }
        if (audioTrack != null) {
            try { audioTrack.stop(); } catch (Exception ignored) {}
            try { audioTrack.release(); } catch (Exception ignored) {}
            audioTrack = null;
        }
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        Log.i(TAG, "Tuner audio stopped");
    }

    // ---------- UI helpers ----------

    private TextView sectionTitle(String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(14f);
        t.setTypeface(null, Typeface.BOLD);
        t.setTextColor(Color.parseColor("#666666"));
        t.setPadding(8, 24, 0, 8);
        return t;
    }

    private LinearLayout card(LinearLayout parent) {
        LinearLayout card = new LinearLayout(this);
        card.setOrientation(LinearLayout.VERTICAL);
        card.setPadding(24, 24, 24, 24);
        card.setBackgroundColor(Color.WHITE);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.WHITE);
        bg.setCornerRadius(16f);
        card.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(0, 0, 0, 16);
        card.setLayoutParams(lp);
        parent.addView(card);
        return card;
    }

    private TextView chip(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(12f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(16, 8, 16, 8);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.parseColor("#E8E8E8"));
        bg.setCornerRadius(20f);
        t.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(8, 0, 8, 0);
        t.setLayoutParams(lp);
        parent.addView(t);
        return t;
    }

    /** Simple text label for monitor rows. */
    private TextView monText(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(13f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(8, 2, 8, 2);
        parent.addView(t);
        return t;
    }

    /** Monospace hex text line. */
    private TextView hexText(LinearLayout parent) {
        TextView t = new TextView(this);
        t.setText("--");
        t.setTextSize(10f);
        t.setTypeface(Typeface.MONOSPACE);
        t.setTextColor(Color.parseColor("#888888"));
        t.setPadding(0, 2, 0, 2);
        parent.addView(t);
        return t;
    }

    private LinearLayout buttonRow(LinearLayout parent) {
        LinearLayout row = new LinearLayout(this);
        row.setOrientation(LinearLayout.HORIZONTAL);
        row.setPadding(0, 8, 0, 8);
        parent.addView(row);
        return row;
    }

    private Button btn(LinearLayout parent, String label, View.OnClickListener l) {
        Button b = new Button(this);
        b.setText(label);
        b.setOnClickListener(l);
        b.setAllCaps(false);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
        lp.setMargins(8, 0, 8, 0);
        b.setLayoutParams(lp);
        parent.addView(b);
        return b;
    }

    /** Convert full byte array to space-separated hex. */
    private static String bytesToHex(byte[] bytes) {
        if (bytes == null || bytes.length == 0) return "(empty)";
        StringBuilder sb = new StringBuilder(bytes.length * 3);
        for (byte b : bytes) {
            sb.append(String.format("%02X ", b & 0xFF));
        }
        sb.setLength(sb.length() - 1); // trim trailing space
        return sb.toString();
    }

    /** Convert first N bytes to hex. */
    private static String bytesToHex(byte[] bytes, int limit) {
        if (bytes == null || bytes.length == 0) return "(empty)";
        int n = Math.min(bytes.length, limit);
        StringBuilder sb = new StringBuilder(n * 3);
        for (int i = 0; i < n; i++) {
            sb.append(String.format("%02X ", bytes[i] & 0xFF));
        }
        sb.setLength(sb.length() - 1);
        if (bytes.length > limit) sb.append(" ...");
        return sb.toString();
    }

    private void playFm900() {
        McuRadioClient radio = McuClient.getInstance().radio();
        int rcBand = radio.setBand(McuRadioClient.BAND_FM);
        int rcRange = radio.setFreqRange(0, 522, 1670, 8750, 10800, 9, 1);
        int rcAnt = radio.setAntPower(true);
        int rcStereo = radio.setSwitch(McuRadioClient.SWITCH_STEREO, 1);
        int rcRds = radio.setSwitch(McuRadioClient.SWITCH_RDS, 1);
        int rcMute = radio.setRegMute(false);  // 先解除静音
        int rcFreq = radio.setFrequency(9344);
        int rcStop = radio.seekStop();
        String msg = "Play 93.4 rc band=" + rcBand
                + " range=" + rcRange
                + " ant=" + rcAnt
                + " stereo=" + rcStereo
                + " rds=" + rcRds
                + " freq=" + rcFreq
                + " stop=" + rcStop
                + " muteOff=" + rcMute;
        Log.i(TAG, msg);
        tvStatus.setText(msg);
    }

    private int lastSignal = 0;
    private int strongFreq = 0;

    private void updateRadio(RadioState s) {
        String bandName = s.band == 0 ? "FM" : (s.band == 1 ? "AM" : "?");
        tvBand.setText(bandName + " Radio");

        double mhz = s.band == 0 ? s.freq / 100.0 : s.freq / 1000.0;
        tvFreq.setText(String.format("%.1f", mhz));
        tvFreqMhz.setText(s.band == 0 ? "MHz" : "KHz");

        tvSignal.setText("Signal: " + (s.signal >= 0 ? s.signal : "--"));
        tvStereo.setText(s.flagStereo == 1 ? "Stereo" : "Mono");
        tvStereo.setTextColor(s.flagStereo == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        String[] seekNames = {"Stop", "AMS", "Fwd", "Bwd", "PTY", "TA"};
        tvSeek.setText("Seek: " + (s.seekMode >= 0 && s.seekMode < seekNames.length ? seekNames[s.seekMode] : "--"));

        // ---- Frame Monitor updates ----
        // valid bit (p[4] bit7) — the key firmware issue indicator
        if (s.valid == 1) {
            tvValid.setText("valid: LOCK");
            tvValid.setTextColor(Color.parseColor("#4CAF50"));
        } else if (s.valid == 0) {
            tvValid.setText("valid: 0");
            tvValid.setTextColor(Color.parseColor("#F44336"));
        } else {
            tvValid.setText("valid: --");
            tvValid.setTextColor(Color.parseColor("#999999"));
        }

        // PTY
        tvPty.setText("PTY: " + (s.pty >= 0 ? s.pty : "--"));

        // Traffic flags
        tvTa.setText("TA: " + (s.flagTa == 1 ? "ON" : (s.flagTa == 0 ? "OFF" : "--")));
        tvTa.setTextColor(s.flagTa == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));
        tvTp.setText("TP: " + (s.flagTp == 1 ? "ON" : (s.flagTp == 0 ? "OFF" : "--")));
        tvTp.setTextColor(s.flagTp == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        // Track strongest signal
        if (s.signal > lastSignal) {
            lastSignal = s.signal;
            strongFreq = s.freq;
            Log.i(TAG, "*** STRONG SIGNAL: " + s.freq + " signal=" + s.signal + " ***");
        }
    }

    @Override
    protected void onDestroy() {
        stopTunerAudio();
        McuClient.getInstance().radio().removeListener(listener);
        McuClient.getInstance().removeFrameListener(rawFrameListener);
        super.onDestroy();
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (!McuClient.getInstance().isReady()) {
            android.widget.Toast.makeText(this, "Service 未连接，5s后重试", android.widget.Toast.LENGTH_SHORT).show();
            new Handler(Looper.getMainLooper()).postDelayed(this::onResume, 5000);
        }
    }
}