Learn and monitor the status of the physical Quiet Mode Switch

Instagram knows how to do this, and we also want it that way.

TLDR: and even no private api
import notify

var token = NOTIFY_TOKEN_INVALID
notify_register_dispatch(
  "com.apple.springboard.ringerstate",
  &token,
  .main
) { token in
  var state: UInt64 = 0
  notify_get_state(token, &state)
  print("Changed to", state == 1 ? "ON" : "OFF")
}

var state: UInt64 = 0
notify_get_state(token, &state)
print("Initial", state == 1 ? "ON" : "OFF")

Googling showed that the most “working” method was described Here. In short, if you play a special sound, and if the end of playback event comes almost instantly, then silent mode is enabled. The link describes in more detail, and also describes why this is an unreliable method.

But something told me that there is always a better way.

Initially, I had a few ideas about where to even start looking.

First, I remembered that many events (for example, touch and keyboard) came to UIApplication as GSEvent structures from the GraphicsServices framework, then GSEvent turned into UIEventand finally UIEvent already sent to -[UIApplication sendEvent:]. To handle GSEventRef y UIApplication there is a private method -[UIApplication handleEvent:]. I set a breakpoint on it and expected it to be called when I toggle silent mode. But the miracle did not happen, the breakpoint did not work, and moreover, pressing the screen did not cause this code either.

I still hoped that someone was telling the application about the mode switch event, but there was not even anything to hook on, and as if there was nowhere to put breakpoints. And then I thought “I’ll put a breakpoint on objc_msgSend!”. And I’ll see if anything is called, and then it will be seen. Unfortunately, this did not help either, switching silent mode did not generate any calls to objc methods at all.

Then it turned out that the first idea with GSEvent was still good, because. I stumbled upon this question on SO: https://stackoverflow.com/questions/24145386/detect-ring-silent-switch-position-change. The author provides a summary of everything he has tried and my eye is hooked on the event types:

kGSEventRingerOff = 1012,
kGSEventRingerOn = 1013,

So they did come…

Then I guessed to look for the word “Ringer” in all loaded symbols. Something told me that there should be something implemented in the system frameworks.

I launched my test application, paused it, and in the debugger I executed

image lookup -r -s "[rR]inger"

I immediately got promising results:

<...>
Summary: AssistantServices`+[AFDeviceRingerSwitchObserver sharedObserver]
Address: AssistantServices[0x000000019d801770] (AssistantServices.__TEXT.__text + 1036984)
Summary: AssistantServices`__46+[AFDeviceRingerSwitchObserver sharedObserver]_block_invoke
Address: AssistantServices[0x000000019d8017ac] (AssistantServices.__TEXT.__text + 1037044)
Summary: AssistantServices`-[AFDeviceRingerSwitchObserver init]
Address: AssistantServices[0x000000019d8018a8] (AssistantServices.__TEXT.__text + 1037296)
Summary: AssistantServices`-[AFDeviceRingerSwitchObserver state]
Address: AssistantServices[0x000000019d8018e0] (AssistantServices.__TEXT.__text + 1037352)
Summary: AssistantServices`-[AFDeviceRingerSwitchObserver addListener:]
Address: AssistantServices[0x000000019d801990] (AssistantServices.__TEXT.__text + 1037528)
Summary: AssistantServices`__44-[AFDeviceRingerSwitchObserver addListener:]_block_invoke
Address: AssistantServices[0x000000019d80199c] (AssistantServices.__TEXT.__text + 1037540)
Summary: AssistantServices`-[AFDeviceRingerSwitchObserver removeListener:]
<...>

At the same time I saw -[UIApplication ringerChanged:]but, as we already understood from the test with objc_msgSendhe was not called.

But AFDeviceRingerSwitchObserver – Looks like it should! Looking at the rest of the methods, I concluded that on AFDeviceRingerSwitchObserver you can subscribe to addListener:and the observer notifies its subscribers of the new state via the method -(void)deviceRingerObserver:(id<Observer>)observer didChangeState:(long)state;

I prefer to do manipulations with the Objective-C Runtime directly in Objective-C.
In order not to suffer with objc_msgSend and coercion of function signatures, I declared the selectors I needed in the helper protocols. They are needed only in order to be able to cast an object to this protocol, and call the desired method in a human way.

@protocol Observer;

@protocol Listener <NSObject>
-(void)deviceRingerObserver:(id<Observer>)observer didChangeState:(long)state;
@end

@protocol Observer<NSObject>
+ (id<Observer>)sharedObserver;
- (void)addListener:(id<Listener>)listener;
@end

I then added an implementation for the listener: and subscribed it to AFDeviceRingerSwitchObserver.sharedObserver:

@interface MyListener: NSObject<Listener>
@end

@implementation MyListener
-(void)deviceRingerObserver:(id)observer didChangeState:(long)state {
  NSLog(@"state: %ld", state);
}
@end

static void enableListener(void) {
  static id<Listener> listener = nil;
  listener = [MyListener new];
  
  Class<Observer> cls = NSClassFromString(@"AFDeviceRingerSwitchObserver");
  [[cls sharedObserver] addListener:_listener];
}

Switching silent mode is intercepted!

2023-06-29 02:12:23.132505+0100 objc[2046:417227] state: 1 // выкл
2023-06-29 02:12:23.689309+0100 objc[2046:417171] state: 2 // вкл

It was already almost a victory, but I would like to understand how it works AFDeviceRingerSwitchObserverfrom where it takes events.

Going into [AFDeviceRingerSwitchObserver init]I saw what it calls-[AFNotifyObserver initWithName:options:queue:delegate:] with an argument "com.apple.springboard.ringerstate". Which in turn uses libnotify to communicate with the system.

The code there is something like this:

#import <notify.h>

void print_state(int token) {
  uint64_t state;
  notify_get_state(token, &state);
  NSLog(@"%@", state == 0 ? @"OFF" : @"ON");
}

int token = NOTIFY_TOKEN_INVALID;
notify_register_dispatch(
  "com.apple.springboard.ringerstate",
  &token,
  dispatch_get_main_queue(),
  ^(int token) { print_state(token); }
);
  
print_state(token);


Interestingly, we can not only subscribe to updates, but also find out the current value! And the best part is that libnotify is not a private API.

Through a private api, you can find out if our switch is on the device at all (there are none on the iPad).

#import <dlfcn.h>

void* h = dlopen(NULL, 0);
  
BOOL(*AFHasRingerSwitch)(void) = dlsym(h, "AFHasRingerSwitch");
NSLog(@"%d", AFHasRingerSwitch());

// AFHasRingerSwitch делает dispatch_once { MGGetBoolAnswer("ringer-switch") }

BOOL(*MGGetBoolAnswer)(CFStringRef) = dlsym(h, "MGGetBoolAnswer");
NSLog(@"%d", MGGetBoolAnswer(CFSTR("ringer-switch")));

Finally, I made a libnotify wrapper that turns source state change events into a Combine Publisher.

let listener = try Notify.Listener(name: "com.apple.springboard.ringerstate") // throws Notify.Status

try listener.value() // reads current value
listener.publisher.sink { ... } // Combine publisher

The code can be found on github: https://gist.github.com/storoj/bc5c0d24dde6b5bb0b5f7fe2706c61e9. But just in case, I’ll put it under the spoiler here.

Notify.swift
import notify
import Combine

enum Notify {}

extension Notify {
  struct Status: Error {
    let rawValue: UInt32
    init(_ rawValue: UInt32) {
      self.rawValue = rawValue
    }
    
    func ok() throws {
      guard rawValue == NOTIFY_STATUS_OK else { throw self }
    }
  }
}

extension Notify {
  struct Token {
    typealias State = UInt64
    typealias RawValue = Int32
    
    var rawValue: RawValue = NOTIFY_TOKEN_INVALID
    
    init(_ rawValue: RawValue) {
      self.rawValue = rawValue
    }
    
    init(dispatch name: String, queue: DispatchQueue = .main, handler: @escaping notify_handler_t) throws {
      try Status(notify_register_dispatch(name, &rawValue, queue, handler)).ok()
    }
    
    init(check name: String) throws {
      try Status(notify_register_check(name, &rawValue)).ok()
    }
    
    func state() throws -> State {
      var state: State = 0
      try Status(notify_get_state(rawValue, &state)).ok()
      return state
    }
    
    func cancel() throws {
      try Status(notify_cancel(rawValue)).ok()
    }
  }
}

extension Notify {
  class Listener {
    private class Helper {
      let name: String
      var token: Token?
      let publisher = PassthroughSubject<UInt64, Status>()
      
      init(name: String) {
        self.name = name
      }
      
      func subscribe() {
        do {
          token = try Token(dispatch: name) { [publisher] token in
            do {
              publisher.send(try Token(token).state())
            } catch {
              publisher.send(completion: .failure(error as! Status))
            }
          }
        } catch {
          publisher.send(completion: .failure(error as! Status))
        }
      }
      
      func cancel() {
        try? token?.cancel()
      }
      
      func value() throws -> UInt64 {
        try Token(check: name).state()
      }
    }
    
    private let helper: Helper
    init(name: String) {
      helper = Helper(name: name)
    }
    
    func value() throws -> UInt64 {
      try helper.value()
    }
    
    lazy var publisher: AnyPublisher<UInt64, Status> = {
      helper.publisher
        .handleEvents(receiveSubscription: { [helper] sub in
          helper.subscribe()
        }, receiveCancel: helper.cancel)
        .share()
        .eraseToAnyPublisher()
    }()
  }
}

Why is the Notify.Listener code so fancy? Publishers can have zero, one, two or more subscribers, and I have been trying for a long time to make sure that notify_register_dispatch firstly, it was called “lazy”, i.e. at the time of the first subscription. And secondly, to notify_cancel called after everyone had unsubscribed.

I hope it was interesting, feel free to ask questions. But, to be honest, while I enjoyed the investigation, I don’t think that the feature with switching sound to video on Instagram had a right to exist. In my opinion, this is still an inappropriate use of a physical switch. ringtonesand it’s probably not worth using this “api” in your applications.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *