iOS: Узнать и отслеживать состояние физического переключателя тихого режима

aea524370511ca2ee5e69c0285ec7191

Инстаграм же так умеет, и мы тоже так хотим.

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 didChangeState:(long)state;

Манипуляции с Objective-C Runtime я предпочитаю делать прямо на Objective-C.

Чтобы не мучаться с objc_msgSend и приведением сигнатур функций, я объявил нужные мне селекторы в протоколах-хелперах. Они нужны только для того, чтобы можно было закастить объект к этому протоколу, и вызвать нужный метод по-человечески.

@protocol Observer;

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

@protocol Observer
+ (id)sharedObserver;
- (void)addListener:(id)listener;
@end

Затем я добавил реализацию для слушателя: и подписал его на AFDeviceRingerSwitchObserver.sharedObserver:

@interface MyListener: NSObject
@end

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

static void enableListener(void) {
  static id listener = nil;
  listener = [MyListener new];
  
  Class 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 

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 

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()
      
      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 = {
      helper.publisher
        .handleEvents(receiveSubscription: { [helper] sub in
          helper.subscribe()
        }, receiveCancel: helper.cancel)
        .share()
        .eraseToAnyPublisher()
    }()
  }
}

Почему код Notify.Listener такой навороченный? У паблишеров могут быть ноль, один, два и более подписчиков, и я долго пытался сделать так, чтобы notify_register_dispatch во-первых вызывался «лениво», т.е. в момент первой подписки. А во-вторых, чтобы notify_cancel вызывался после того, как все отписались.

Надеюсь, было интересно, задавайте вопросы. Но, честно говоря, хоть я и получил удовольствие от расследования, мне не кажется, что фича с переключением звука на видео в инстаграме имела право на существование. По-моему, это всё же нецелевое применение физического выключателя мелодии звонка, и использовать это «апи» в своих приложениях скорее не стоит.

© Habrahabr.ru