Инстаграм же так умеет, и мы тоже так хотим.
TLDR: и даже никакого приватного апи
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")
Гугление показало, что самый "рабочий" способ был описан здесь. Вкратце, если проиграть особый звук, и если событие окончания проигрывания приходит почти мгновенно – значит silent mode включён. По ссылке описано более подробно, и так же описано почему это ненадёжный способ.
Но что-то мне подсказывало, что всегда есть способ получше.
Изначально у меня было несколько идей о том, с чего хотя бы начать поиски.
Сперва я вспомнил о том, что многие события (например тачи и клавиатура) приходили в UIApplication как структуры GSEvent из фреймворка GraphicsServices, далее GSEvent
превращались в UIEvent
, и наконец UIEvent
уже посылались в -[UIApplication sendEvent:]
. Для обработки GSEventRef у UIApplication
есть приватный метод -[UIApplication handleEvent:]
. Я установил на него брейкпоинт и ожидал, что он вызовется, когда я переключу silent mode. Но чуда не случилось, брейкпоинт не сработал, и более того, нажатия на экран так же не вызывали этот код.
Я всё же надеялся, что кто-то да сообщает приложению о событии переключения режима, но было даже не за что зацепиться, и как будто бы некуда было ставить брейкпоинты. И тут я подумал "а поставлю-ка я брейкпоинт на objc_msgSend
!". И посмотрю, вызовется ли хоть что-нибудь, а дальше будет видно. К сожалению, это тоже не помогло, переключение silent mode не порождало вообще никаких вызовов методов objc.
Далее оказалось, что первая идея с GSEvent была всё же хороша, т.к. я наткнулся на этот вопрос на SO: https://stackoverflow.com/questions/24145386/detect-ring-silent-switch-position-change. Автор приводит резюме всему, что он пробовал, и мой глаз зацепился за типы события:
kGSEventRingerOff = 1012,
kGSEventRingerOn = 1013,
Значит, они всё же когда-то приходили...
Затем я догадался поискать по всем загруженным символ слово "Ringer". Что-то мне подсказывало, что в системных фреймворках должно бы быть что-то реализованное.
Я запустил своё тестовое приложение, запаузил его, и в отладчике выполнил
image lookup -r -s "[rR]inger"
Я тут же получил многообещающие результаты:
<...>
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:]
<...>
Заодно я увидел и -[UIApplication ringerChanged:]
, но, как мы уже поняли из теста с objc_msgSend
, он не вызывался.
Но AFDeviceRingerSwitchObserver
– выглядит как то, что надо! Глянув на остальные методы, я сделал вывод, что на AFDeviceRingerSwitchObserver
можно подписаться в addListener:
, а observer оповещает своих подписчиков о новом состоянии через метод -(void)deviceRingerObserver:(id<Observer>)observer didChangeState:(long)state;
Манипуляции с Objective-C Runtime я предпочитаю делать прямо на Objective-C.
Чтобы не мучаться с objc_msgSend и приведением сигнатур функций, я объявил нужные мне селекторы в протоколах-хелперах. Они нужны только для того, чтобы можно было закастить объект к этому протоколу, и вызвать нужный метод по-человечески.
@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
Затем я добавил реализацию для слушателя: и подписал его на 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];
}
Переключение silent mode перехватывается!
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 // вкл
Это была уже почти победа, но хотелось бы понять как работает сам AFDeviceRingerSwitchObserver
, откуда берёт события.
Зайдя в [AFDeviceRingerSwitchObserver init]
, я увидел, что он вызывает-[AFNotifyObserver initWithName:options:queue:delegate:]
с аргументом "com.apple.springboard.ringerstate"
. Который, в свою очередь, использует libnotify для коммуникации с системой.
Код там примерно такой:
#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);
Что интересно, мы можем не только подписаться на обновления, но и узнавать текущее значение! А самое приятное то, что libnotify это не приватное API.
Через приватное же апи можно узнать, а есть ли вообще наш переключатель на устройстве (на 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")));
Наконец, я сделал обёртку над libnotify, которая превращает события изменения состояния источника в 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
Код можно найти на гитхабе: https://gist.github.com/storoj/bc5c0d24dde6b5bb0b5f7fe2706c61e9. Но на всякий случай вставлю и под спойлер сюда.
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()
}()
}
}
Почему код Notify.Listener такой навороченный? У паблишеров могут быть ноль, один, два и более подписчиков, и я долго пытался сделать так, чтобы notify_register_dispatch
во-первых вызывался "лениво", т.е. в момент первой подписки. А во-вторых, чтобы notify_cancel
вызывался после того, как все отписались.
Надеюсь, было интересно, задавайте вопросы. Но, честно говоря, хоть я и получил удовольствие от расследования, мне не кажется, что фича с переключением звука на видео в инстаграме имела право на существование. По-моему, это всё же нецелевое применение физического выключателя мелодии звонка, и использовать это "апи" в своих приложениях скорее не стоит.
saltpepper
Не владею яблоком, но ненавижу, когда изобретают такие костыли чтобы обойти дизайн-код и привычный пользователю режим работы. Так порождаются всякие мерзкие свистогуделки отвлекающие внимание и дрянной дизайн. Вам же пользователь говорит физическим переключателем: "не хочу ничего видеть от вас", остальное уже не ваше дело. Это дыра в безопасности, и надеюсь ее прикроют, так же как просмотр цвета ссылок из двавскрипта для детектирования посещённых сайтов.
glycol
Это не дыра, проиграть звук в беззвучном режиме не получится, можно просто отловить переключение этого переключателя. Зачем? Я не могу придумать, в каком случае это нужно
saltpepper
Я тоже не представляю себе, зачем это нужно. Но одно дело если это в апи можно отловить - без проблем. Если нет - то разработчику by design эта информация не должна быть доступна. Бесшумные звуки проигрывать и другие трюки - это уже явный хак, позволяющий извлечь недоступную информацию. Прикроют одно, потом новые костыли придумывать? Автор, впрочем это тоже упоминает. А есть подробное описание того как это а инстаграме используется?
vladkorotnev
Айось при выключенном звонке не глушит звук медиа, в отличие от некоторых сборок андроида, а только именно что системные звуки, звуковые эффекты и рингтон.
Поэтому когда в ленте попадётся видео со звуком — несмотря на то, что щёлкалка у вас стоит в положении "без звука", очередной хит тиктока начнёт орать на весь автобус.
Если, конечно, громкость медиа не была заблаговременно выкручена в ноль (что на айоси тоже не делается без костылей, ибо регулятор громкости один, а не микшер, как на андроиде)
Поэтому инстаграм детектит положение щёлкалки сам, и если звук отключён, то глушит его и в видео/сторизах.