@@ -273,6 +273,161 @@ describe("ListenerProvider detect events", () => {
273273 expect ( stopSpy ) . not . toHaveBeenCalled ( ) ;
274274 } ) ;
275275
276+ test ( "does not stop after non-trigger MicStopped when a trigger app is still active" , async ( ) => {
277+ const store = createListenerStore ( ) ;
278+ const stopSpy = vi . fn ( ) ;
279+
280+ store . setState ( { stop : stopSpy } ) ;
281+ store . getState ( ) . setTriggerAppIds ( [ "us.zoom.xos" ] ) ;
282+ setStoreActive ( store ) ;
283+ listMicUsingApplicationsMock . mockResolvedValue ( {
284+ status : "ok" ,
285+ data : [ { id : "us.zoom.xos" , name : "Zoom" } ] ,
286+ } ) ;
287+
288+ render (
289+ < ListenerProvider store = { store } >
290+ < div > child</ div >
291+ </ ListenerProvider > ,
292+ ) ;
293+
294+ await vi . waitFor ( ( ) => expect ( listenMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
295+
296+ const handler = listenMock . mock . calls [ 0 ] ?. [ 0 ] ;
297+ expect ( handler ) . toBeTypeOf ( "function" ) ;
298+
299+ vi . useFakeTimers ( ) ;
300+ listMicUsingApplicationsMock . mockClear ( ) ;
301+
302+ handler ( {
303+ payload : {
304+ type : "micStopped" ,
305+ apps : [ { id : "/opt/homebrew/bin/ffmpeg" , name : "ffmpeg" } ] ,
306+ } ,
307+ } ) ;
308+
309+ await vi . advanceTimersByTimeAsync ( AUTO_STOP_CONFIRM_DELAY_MS ) ;
310+
311+ expect ( listMicUsingApplicationsMock ) . toHaveBeenCalledTimes ( 1 ) ;
312+ expect ( stopSpy ) . not . toHaveBeenCalled ( ) ;
313+ } ) ;
314+
315+ test ( "auto-stops when MicStopped omits the trigger app and no trigger app remains active (regression: #5436)" , async ( ) => {
316+ const store = createListenerStore ( ) ;
317+ const stopSpy = vi . fn ( ) ;
318+
319+ store . setState ( { stop : stopSpy } ) ;
320+ store . getState ( ) . setTriggerAppIds ( [ "com.microsoft.teams2" ] ) ;
321+ setStoreActive ( store ) ;
322+
323+ render (
324+ < ListenerProvider store = { store } >
325+ < div > child</ div >
326+ </ ListenerProvider > ,
327+ ) ;
328+
329+ await vi . waitFor ( ( ) => expect ( listenMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
330+
331+ const handler = listenMock . mock . calls [ 0 ] ?. [ 0 ] ;
332+ expect ( handler ) . toBeTypeOf ( "function" ) ;
333+
334+ vi . useFakeTimers ( ) ;
335+ listMicUsingApplicationsMock . mockClear ( ) ;
336+
337+ handler ( {
338+ payload : {
339+ type : "micStopped" ,
340+ apps : [ { id : "pid:42" , name : "Microsoft Teams Helper" } ] ,
341+ } ,
342+ } ) ;
343+
344+ await vi . advanceTimersByTimeAsync ( AUTO_STOP_CONFIRM_DELAY_MS ) ;
345+
346+ expect ( listMicUsingApplicationsMock ) . toHaveBeenCalledTimes ( 1 ) ;
347+ expect ( stopSpy ) . toHaveBeenCalledTimes ( 1 ) ;
348+ } ) ;
349+
350+ test ( "auto-stops Teams running in a browser when the browser no longer uses the mic (regression: #5436)" , async ( ) => {
351+ const store = createListenerStore ( ) ;
352+ const stopSpy = vi . fn ( ) ;
353+
354+ store . setState ( { stop : stopSpy } ) ;
355+ store . getState ( ) . setTriggerAppIds ( [ "company.thebrowser.Browser" ] ) ;
356+ setStoreActive ( store ) ;
357+
358+ render (
359+ < ListenerProvider store = { store } >
360+ < div > child</ div >
361+ </ ListenerProvider > ,
362+ ) ;
363+
364+ await vi . waitFor ( ( ) => expect ( listenMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
365+
366+ const handler = listenMock . mock . calls [ 0 ] ?. [ 0 ] ;
367+ expect ( handler ) . toBeTypeOf ( "function" ) ;
368+
369+ vi . useFakeTimers ( ) ;
370+ listMicUsingApplicationsMock . mockClear ( ) ;
371+
372+ handler ( {
373+ payload : {
374+ type : "micStopped" ,
375+ apps : [ { id : "company.thebrowser.Browser" , name : "Arc" } ] ,
376+ } ,
377+ } ) ;
378+
379+ await vi . advanceTimersByTimeAsync ( AUTO_STOP_CONFIRM_DELAY_MS ) ;
380+
381+ expect ( listMicUsingApplicationsMock ) . toHaveBeenCalledTimes ( 1 ) ;
382+ expect ( stopSpy ) . toHaveBeenCalledTimes ( 1 ) ;
383+ } ) ;
384+
385+ test ( "keeps direct trigger auto-stop confidence when a later helper stop arrives" , async ( ) => {
386+ const store = createListenerStore ( ) ;
387+ const stopSpy = vi . fn ( ) ;
388+
389+ store . setState ( { stop : stopSpy } ) ;
390+ store . getState ( ) . setTriggerAppIds ( [ "us.zoom.xos" ] ) ;
391+ setStoreActive ( store ) ;
392+ listMicUsingApplicationsMock . mockResolvedValue ( {
393+ status : "error" ,
394+ error : "failed to read mic snapshot" ,
395+ } ) ;
396+
397+ render (
398+ < ListenerProvider store = { store } >
399+ < div > child</ div >
400+ </ ListenerProvider > ,
401+ ) ;
402+
403+ await vi . waitFor ( ( ) => expect ( listenMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
404+
405+ const handler = listenMock . mock . calls [ 0 ] ?. [ 0 ] ;
406+ expect ( handler ) . toBeTypeOf ( "function" ) ;
407+
408+ vi . useFakeTimers ( ) ;
409+ listMicUsingApplicationsMock . mockClear ( ) ;
410+
411+ handler ( {
412+ payload : {
413+ type : "micStopped" ,
414+ apps : [ { id : "us.zoom.xos" , name : "Zoom" } ] ,
415+ } ,
416+ } ) ;
417+
418+ handler ( {
419+ payload : {
420+ type : "micStopped" ,
421+ apps : [ { id : "pid:42" , name : "Zoom Helper" } ] ,
422+ } ,
423+ } ) ;
424+
425+ await vi . advanceTimersByTimeAsync ( AUTO_STOP_CONFIRM_DELAY_MS ) ;
426+
427+ expect ( listMicUsingApplicationsMock ) . toHaveBeenCalledTimes ( 1 ) ;
428+ expect ( stopSpy ) . toHaveBeenCalledTimes ( 1 ) ;
429+ } ) ;
430+
276431 test ( "passes ignorable app ids and footer metadata through mic-detected notifications" , async ( ) => {
277432 const store = createListenerStore ( ) ;
278433
@@ -466,67 +621,73 @@ describe("ListenerProvider detect events", () => {
466621 expect ( stopSpy ) . toHaveBeenCalledTimes ( 1 ) ;
467622 } ) ;
468623
469- test ( "asks before stopping when a browser meeting stops well before the scheduled end" , async ( ) => {
470- const store = createListenerStore ( ) ;
471- const stopSpy = vi . fn ( ) ;
472- const now = new Date ( "2026-05-19T10:05:00.000Z" ) ;
473-
474- store . setState ( { stop : stopSpy } ) ;
475- store . getState ( ) . setTriggerAppIds ( [ "com.google.Chrome" ] ) ;
476- setStoreActive ( store ) ;
477- ( useStoreMock as any ) . mockReturnValue (
478- mockSessionEventStore ( {
479- started_at : "2026-05-19T10:00:00.000Z" ,
480- ended_at : "2026-05-19T10:30:00.000Z" ,
481- } ) ,
482- ) ;
483-
484- render (
485- < ListenerProvider store = { store } >
486- < div > child</ div >
487- </ ListenerProvider > ,
488- ) ;
489-
490- await vi . waitFor ( ( ) => expect ( listenMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
491-
492- const handler = listenMock . mock . calls [ 0 ] ?. [ 0 ] ;
493- expect ( handler ) . toBeTypeOf ( "function" ) ;
494-
495- vi . useFakeTimers ( ) ;
496- vi . setSystemTime ( now ) ;
497- listMicUsingApplicationsMock . mockClear ( ) ;
498-
499- handler ( {
500- payload : {
501- type : "micStopped" ,
502- apps : [ { id : "com.google.Chrome" , name : "Google Chrome" } ] ,
503- } ,
504- } ) ;
505-
506- await vi . advanceTimersByTimeAsync ( AUTO_STOP_CONFIRM_DELAY_MS ) ;
507-
508- expect ( listMicUsingApplicationsMock ) . toHaveBeenCalledTimes ( 1 ) ;
509- expect ( stopSpy ) . not . toHaveBeenCalled ( ) ;
510- const notification = showNotificationMock . mock . calls [ 0 ] ?. [ 0 ] ;
511- expect ( parseAutoStopEndedNotificationKey ( notification . key ) ) . toBe (
512- "session-1" ,
513- ) ;
514- expect ( notification ) . toEqual ( {
515- key : expect . stringContaining ( "auto-stop-ended:session-1" ) ,
516- title : "Did your meeting end?" ,
517- message :
518- "Google Chrome stopped using the microphone before the scheduled end time." ,
519- timeout : { secs : 60 , nanos : 0 } ,
520- source : null ,
521- start_time : null ,
522- participants : null ,
523- event_details : null ,
524- action_label : "Stop recording" ,
525- options : null ,
526- footer : null ,
527- icon : { type : "bundle_id" , bundle_id : "com.google.Chrome" } ,
528- } ) ;
529- } ) ;
624+ test . each ( [
625+ { id : "com.google.Chrome" , name : "Google Chrome" } ,
626+ { id : "at.studio.AsideBrowser" , name : "Aside" } ,
627+ { id : "net.imput.helium" , name : "Helium" } ,
628+ ] ) (
629+ "asks before stopping when $name stops well before the scheduled end" ,
630+ async ( browser ) => {
631+ const store = createListenerStore ( ) ;
632+ const stopSpy = vi . fn ( ) ;
633+ const now = new Date ( "2026-05-19T10:05:00.000Z" ) ;
634+
635+ store . setState ( { stop : stopSpy } ) ;
636+ store . getState ( ) . setTriggerAppIds ( [ browser . id ] ) ;
637+ setStoreActive ( store ) ;
638+ ( useStoreMock as any ) . mockReturnValue (
639+ mockSessionEventStore ( {
640+ started_at : "2026-05-19T10:00:00.000Z" ,
641+ ended_at : "2026-05-19T10:30:00.000Z" ,
642+ } ) ,
643+ ) ;
644+
645+ render (
646+ < ListenerProvider store = { store } >
647+ < div > child</ div >
648+ </ ListenerProvider > ,
649+ ) ;
650+
651+ await vi . waitFor ( ( ) => expect ( listenMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
652+
653+ const handler = listenMock . mock . calls [ 0 ] ?. [ 0 ] ;
654+ expect ( handler ) . toBeTypeOf ( "function" ) ;
655+
656+ vi . useFakeTimers ( ) ;
657+ vi . setSystemTime ( now ) ;
658+ listMicUsingApplicationsMock . mockClear ( ) ;
659+
660+ handler ( {
661+ payload : {
662+ type : "micStopped" ,
663+ apps : [ browser ] ,
664+ } ,
665+ } ) ;
666+
667+ await vi . advanceTimersByTimeAsync ( AUTO_STOP_CONFIRM_DELAY_MS ) ;
668+
669+ expect ( listMicUsingApplicationsMock ) . toHaveBeenCalledTimes ( 1 ) ;
670+ expect ( stopSpy ) . not . toHaveBeenCalled ( ) ;
671+ const notification = showNotificationMock . mock . calls [ 0 ] ?. [ 0 ] ;
672+ expect ( parseAutoStopEndedNotificationKey ( notification . key ) ) . toBe (
673+ "session-1" ,
674+ ) ;
675+ expect ( notification ) . toEqual ( {
676+ key : expect . stringContaining ( "auto-stop-ended:session-1" ) ,
677+ title : "Did your meeting end?" ,
678+ message : `${ browser . name } stopped using the microphone before the scheduled end time.` ,
679+ timeout : { secs : 60 , nanos : 0 } ,
680+ source : null ,
681+ start_time : null ,
682+ participants : null ,
683+ event_details : null ,
684+ action_label : "Stop recording" ,
685+ options : null ,
686+ footer : null ,
687+ icon : { type : "bundle_id" , bundle_id : browser . id } ,
688+ } ) ;
689+ } ,
690+ ) ;
530691
531692 test ( "auto-stops browser meetings inside the scheduled end window" , async ( ) => {
532693 const store = createListenerStore ( ) ;
0 commit comments