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 UIEvent
and 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_msgSend
he 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 AFDeviceRingerSwitchObserver
from 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.