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

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 вызывался после того, как все отписались.

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

Комментарии (5)


  1. saltpepper
    29.06.2023 06:57
    -6

    Не владею яблоком, но ненавижу, когда изобретают такие костыли чтобы обойти дизайн-код и привычный пользователю режим работы. Так порождаются всякие мерзкие свистогуделки отвлекающие внимание и дрянной дизайн. Вам же пользователь говорит физическим переключателем: "не хочу ничего видеть от вас", остальное уже не ваше дело. Это дыра в безопасности, и надеюсь ее прикроют, так же как просмотр цвета ссылок из двавскрипта для детектирования посещённых сайтов.


    1. glycol
      29.06.2023 06:57
      +1

      Это не дыра, проиграть звук в беззвучном режиме не получится, можно просто отловить переключение этого переключателя. Зачем? Я не могу придумать, в каком случае это нужно


      1. saltpepper
        29.06.2023 06:57
        +1

        Я тоже не представляю себе, зачем это нужно. Но одно дело если это в апи можно отловить - без проблем. Если нет - то разработчику by design эта информация не должна быть доступна. Бесшумные звуки проигрывать и другие трюки - это уже явный хак, позволяющий извлечь недоступную информацию. Прикроют одно, потом новые костыли придумывать? Автор, впрочем это тоже упоминает. А есть подробное описание того как это а инстаграме используется?


        1. vladkorotnev
          29.06.2023 06:57
          +2

          Айось при выключенном звонке не глушит звук медиа, в отличие от некоторых сборок андроида, а только именно что системные звуки, звуковые эффекты и рингтон.


          Поэтому когда в ленте попадётся видео со звуком — несмотря на то, что щёлкалка у вас стоит в положении "без звука", очередной хит тиктока начнёт орать на весь автобус.


          Если, конечно, громкость медиа не была заблаговременно выкручена в ноль (что на айоси тоже не делается без костылей, ибо регулятор громкости один, а не микшер, как на андроиде)


          Поэтому инстаграм детектит положение щёлкалки сам, и если звук отключён, то глушит его и в видео/сторизах.


  1. at_nil
    29.06.2023 06:57

    ????