20210112のiOSに関する記事は4件です。

iOS「自動更新サブスクリプション」のサーバー通知をRubyで受け取る

iOS「自動更新サブスクリプション」のサーバー通知をRubyで受け取る

iOS「自動更新サブスクリプション」のサーバー通知をRubyで受け取る必要があり、いろいろ調べたので備忘録としてまとめてみます!

構成

マルチプラットフォーム型のサービス
iOSアプリ:Swift
サーバーサイド:Ruby (Ruby on Rails)
※サーバーサイドでの決済システムは他のサービスを利用

おさらい:サーバー通知とは

サーバーサイドのURLをAppStoreConnectに登録しておくことで、自動更新サブスクリプションのステータスが変更したときにAppleから通知を取得することができます。

この機能があることで、入会・解約・アップグレード・ダウングレードなども配信者側はオンタイムで確認・知ることができます。

サーバー通知を受信するURL(サーバー)の登録

AppStoreConnect > 該当アプリページ を開きます。
該当アプリページのApp情報(名前などを登録するところ)の右下にある「App Storeサーバー通知のURL」にURLを登録すると、後述のタイミングでAppleStoreからの通知が登録したURLに送られてくるようになります!

サーバー通知が送られてくるタイミング

ユーザーのイベント 通知タイプ
初回購入 INITIAL_BUY
アップグレード CANCEL,DID_CHANGE_RENEWAL_STATUS, INTERACTIVE_RENEWAL
ダウングレード DID_CHANGE_RENEWAL_PREF
期限が切れた後に再購読 DID_CHANGE_RENEWAL_STATUS
期限が切れた後に別のサブスクを購読 INTERACTIVE_RENEWAL, DID_CHANGE_RENEWAL_STATUS
購読をキャンセル DID_CHANGE_RENEWAL_STATUS
Appleによる返金 CANCEL,DID_CHANGE_RENEWAL_STATUS
決済の問題でサブスクリプションの更新に失敗 DID_FAIL_TO_RENEW
ユーザーへ払い戻し REFUND
サブスクリプションの値上げに同意した PRICE_INCREASE_CONSENT
自動更新が成功 DID_RENEW

送られてくるJSONの形式

{
    "auto_renew_product_id": " ",
    "auto_renew_status": "false",
    "auto_renew_status_change_date": "2000-00-00 00:00:00 Etc/GMT",
    "auto_renew_status_change_date_ms": "0000000000000",
    "auto_renew_status_change_date_pst": "2000-00-00 00:00:00America/Los_Angeles",
    "environment": "Sandbox",
    "latest_receipt": " ", // Base64エンコードされたレシート 
    "latest_receipt_info": {
        "bid": " ",
        "bvrs": "1",
        "cancellation_date": "0000000000000",
        "expires_date": "0000000000000",
        "expires_date_formatted": "2000-00-00 00:00:00 Etc/GMT",
        "expires_date_formatted_pst": "2000-00-00 00:00:00 America/Los_Angeles",
        "is_in_intro_offer_period": "false",
        "is_trial_period": "false",
        "item_id": "1486750613",
        "original_purchase_date": "2000-00-00 00:00:00 Etc/GMT",
        "original_purchase_date_ms": "0000000000000",
        "original_purchase_date_pst": "2000-00-00 00:00:00 America/Los_Angeles",
        "original_transaction_id": "000000000000000",
        "product_id": " ",
        "purchase_date": "2000-00-00 00:00:00 Etc/GMT",
        "purchase_date_ms": "0000000000000",
        "purchase_date_pst": "2000-00-00 00:00:00America/Los_Angeles",
        "quantity": "1",
        "subscription_group_identifier": "00000000",
        "transaction_id": "000000000000000",
        "unique_identifier": "3bf0388bbca73e22cbfbce8e7b5b4c8181047dbc",
        "unique_vendor_identifier": "D89BF8D6-3B46-4F1E-895A-1061ECD31178",
        "version_external_identifier": "0",
        "web_order_line_item_id": "000000000000000"
    },
    "notification_type": "INITIAL_BUY",
    "password": " "
}

送られてくるJSONの各プロパティの意味

基本はこちらの公式サイトを見ることでわかります!
公式参考サイト

主要で利用したい値だけ今回まとめてみます
※オフィシャルサイトの英文をグーグル翻訳しただけの情報になります。正確な情報は公式サイトを確認してください。

フィールド名 意味
auto_renew_status 自動更新可能なサブスクリプション製品の現在の更新ステータス
auto_renew_status_change_date ユーザーが自動更新可能なサブスクリプションの更新ステータスをオンまたはオフにした時刻。
original_transaction_id 最初の購入のトランザクションID。この値は、ユーザーが購入を復元するか、サブスクリプションを更新する場合を除いて、同じです。
environment AppStoreがレシートを生成した環境。
latest_receipt Base64でエンコードされた最新のトランザクションレシート
latest_receipt_info 値のJSON表現。このフィールドはレシートの配列ですが、サーバー間通知では単一のオブジェクトです
cancellation_date アップルカスタマーサポートがトランザクションをキャンセルした時刻。このフィールドは、返金されたトランザクションにのみ表示されます。
cancellation_reason 返金された取引の理由。顧客がトランザクションをキャンセルすると、App Storeは顧客に返金を行い、このキーの値を提供します。の値は“1” 、アプリ内の実際の問題または認識された問題が原因で顧客がトランザクションをキャンセルしたことを示します。の値は“0” 、トランザクションが別の理由でキャンセルされたことを示します。たとえば、顧客が誤って購入した場合です。
expires_date サブスクリプションの有効期限が切れる時間、またはサブスクリプションが更新される時間
is_trial_period サブスクリプションが無料試用期間内にあるかどうかの指標。
is_upgraded ユーザーがアップグレードしたためにシステムがサブスクリプションをキャンセルしたことを示すインジケーター。このフィールドは、アップグレードトランザクションの場合にのみ表示されます。
product_id 購入した製品の一意の識別子。この値は、App Store Connectで製品を作成するときに指定し、トランザクションのプロパティに格納されているオブジェクトのプロパティに対応します。
purchase_date App Storeが、ISO 8601標準と同様の日時形式で、サブスクリプションの購入または更新に対してユーザーのアカウントに請求した時刻。
quantity 購入した消耗品の数。
subscription_group_identifier サブスクリプションが属するサブスクリプショングループの識別子。このフィールドの値は、SKProductのプロパティと同じです。
transaction_id 購入、復元、更新などのトランザクションの一意の識別子
notification_type 通知をトリガーしたサブスクリプションイベント
password レシートを検証するときにrequestBodyのpasswordフィールドに送信する共有シークレットと同じ値

送られてくるJSONをRubyで処理するコード

一言で言うと、上記のJSON形式を処理し、タイミング・各プロパティの値に合わせてアプリの処理を記載します。

#例としてreceive_appserver_notificationメソッドとして作成します
def receive_appserver_notification
    unified_receipt = params['unified_receipt'].as_json
    latest_receipt = unified_receipt['latest_receipt']
    latest_receipt_info = unified_receipt['latest_receipt_info']

   @mTestObj = TestObj.new()  #保存用のモデル、オブジェクトを用意
   @mTestObj.auto_renew_product_id = unified_receipt['auto_renew_product_id']
   @mTestObj.expires_date = latest_receipt_info['expires_date']
   #==以下省略==

   #処理があれば記載
   if @mTestObj.notification_type == 'INITIAL_BUY'
       #例えば、初期購入だった場合の処理・・・  
   end

   #保存
   begin 
      @mTestObj.save
      puts "保存完了!!"
   rescue => exception

   end

end


まとめ

1.AppStoreConnectで受信するURLを設定しましょう
2.送られてくる形式、パラメーターを理解しましょう
3.サーバーで受け取り、jsonを処理しましょう
4.値に合わせて、必要な処理を実装しましょう

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSアプリで位置情報を取得するときに配慮する点をまとめてみた③

前回の続き↓
iOSアプリで位置情報を取得するときに配慮する点をまとめてみた②

はじめに

iOSアプリで位置情報を取得するときに配慮する点をまとめた内容となっています。

前回までのあらすじ

前回、アプリの位置情報サービスの有無バックグラウンド状態から戻った時も
チェックをしなければいけないと書きました。

アプリがバックグラウンド状態から戻った時にある処理をするにはAppDelegateapplicationWillEnterForeground(_ application: UIApplication)で処理するんでしたね。

そして、アラートを表示したいのでViewController内で処理するためにNotificationCenterを使いました。

バックグラウンド状態から戻ったときにチェックする(アプリ)

では、実際にコードを書いてみます。
※長くなるので前回のソースコードから一部抜粋

今回も、アラートの表示はこの方法を用います↓
UIAlertControllerをファイルを分けて実装してみる

ViewController
import UIKit
import CoreLocation

final class ViewController: UIViewController {

   private var locationManager: CLLocationManager = {
        var locationManager = CLLocationManager()
        locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
        locationManager.distanceFilter = 5
        return locationManager
    }()

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    // バックグラウンド状態から戻ってきた時に呼ばれるメソッド(自分で作る)
    @objc private func willEnterForeground() {
        // 端末の位置情報サービスがオンの場合      
        if CLLocationManager.locationServicesEnabled() {
            // 位置情報サービスの認証ステータスを取得
            let status = CLLocationManager.authorizationStatus()

            switch status {

            // 許可しない場合
            case .denied:
                // アラート表示
                Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                    // 設定アプリに画面遷移する
                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                }

            default:
                break
            }
        }

    // 端末の位置情報サービスがオフの場合
    }else {
        // アラート表示
        Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます") 
    }
}

端末の位置情報サービスがオンかオフかを分岐して
オンの場合にアプリの位置情報サービスの有無をチェックしています。

ビルドしてみると、ちゃんとチェックされてますね↓

※バグ?のせいなのか、たまに位置情報サービスの認証ステータスが次回確認になっていますが
本来はなしになります。
ezgif.com-gif-maker3.gif
因みに、この部分は.deniedだけのケースのみでOKです。

理由は認証ステータスが変更されればデリゲートメソッドが呼ばれますし、設定アプリに画面遷移するのは.deniedの場合のみで、その時に何もしないで戻った場合をチェックしたいので他の認証ステータスの記述は必要ないです。

ここで、一旦、全体のソースコードを見てみましょう。

ViewController
import UIKit
import CoreLocation

final class ViewController: UIViewController {

    private var locationManager: CLLocationManager = {
       var locationManager = CLLocationManager()
       locationManager.distanceFilter = 5
       locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
       return locationManager
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        locationManager.requestWhenInUseAuthorization()
        locationManager.delegate = self
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    @objc private func willEnterForeground() {     
        if CLLocationManager.locationServicesEnabled() {

            let status = CLLocationManager.authorizationStatus()

            switch status {

            case .denied:
                Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                }

            default:
                break
            }

    }else {
        Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます") 
    }
  }
}

extension ViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
          if CLLocationManager.locationServicesEnabled() {

        let status = manager.authorizationStatus

        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            manager.startUpdatingLocation()

        case .notDetermined:
            manager.requestWhenInUseAuthorization()

        case .denied:
            Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
            }

        case .restricted:
            break           

        default:
            break
        }
      }else {
         Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます") 
      }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let gps = manager.location?.coordinate else {
            return
        }
        manager.stopUpdatingLocation()
        let lat = gps.latitude
        let lng = gps.longitude
        print("経度:\(String(describing: lat)), 緯度:\(String(describing: lng))")
    }
}
Alert
import UIKit

final class Alert {
    static func okAlert(vc: UIViewController,title: String, message: String, handler: ((UIAlertAction) -> Void)? = nil) {
        let okAlertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        okAlertVC.addAction(UIAlertAction(title: "OK", style: .default, handler: handler))
        vc.present(okAlertVC, animated: true, completion: nil)
    }
}

端末・アプリの位置情報サービスの有無や、変更された認証ステータスの処理等ここまで
色んな所に配慮してきました。

後は、お気づきの方がいらっしゃると思いますが
認証ステータスが.restrictedの場合は何もしていませんね。

ここは、Appleのドキュメントを見てみるとペアレンタルコントロールなどの何らかの制限があるために
ユーザーが認証ステータスを変更できない状態に呼ばれる認証ステータスとなっています↓
CLAuthorization Status .restricted

ずっとbreakにしていましたが、アラートなどでユーザーに伝えたほうがいいので改修しときましょう。

ViewController
      case .restricted:
          Alert.okAlert(vc: self, title: "位置情報サービスの使用を\n許可されていません", message: "何らかの制限が掛かっています")

iOS14以外のバージョン対応

現状、iOS14以降を想定して位置情報の取得をしてきましたが
iOS14以外のバージョンでも位置情報を取得したい場合が開発していると出てくると思います。

その場合、使用するメソッドが違ったり、Appleが非推奨にしているコード等も登場して
多少、複雑になってきます。

では、実際にアプリのバージョンを下げてみて実装していきましょう。

私はこちらの記事を拝見して、iOSのバージョンを下げましたので参考にしてみて下さい↓
世界一詳細に全部日本語でXcode11で作った新規プロジェクトをiOS12以前で実行できるところまで解説する

推奨・非推奨コード

では、これでiOS11 ~ iOS14に対応するアプリとなりました。
実装する前にAppleが推奨・非推奨しているコードを紹介していきます。

※私の記事に関係があるコードだけを紹介しますのでご了承ください。

// CLLocationManagerクラスのインスタンス初期化および、認証ステータスが変更されたら呼ばれるデリゲートメソッド

// 推奨していない iOS 4.2–14.0
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus)

// 推奨している iOS14-
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager)
// 位置情報の認証ステータス取得

// 推奨していない iOS 4.2–14.0
func authorizationStatus() -> CLAuthorizationStatus

// 推奨している iOS14-
var authorizationStatus: CLAuthorizationStatus

こんな感じに変更されています。

後は、iOS14から正確な位置精度という項目が新たに追加されました。
簡単に言うと、位置情報の精度を正確にするか曖昧にするかを自分で選択できるようになったんですね。

下記のコードのように、選択肢によって分岐することができます↓

switch locationManager.accuracyAuthorization {

      // 正確な位置情報を取得した場合  
      case .fullAccuracy:

      // 曖昧な位置情報を取得した場合 
      case .reducedAccuracy:

      default:
          break
}

ここは開発するアプリによって変わってきますので、よく吟味した方が良い部分ですね。
こちらの記事が詳しく書いていますので参考にしてみて下さい↓
iOS14でのCore Location変更点
iOS 14 でさらに強化された位置情報まわりのプライバシー

※今回は、位置情報の取得で配慮する点をまとめた記事なので
あまり正確かどうかは関係ないので紹介だけに留めておきます。

注目してほしい部分は、最初の方で説明した推奨・非推奨のコードです。
iOS14以前のバージョンに対応させるには非推奨のコードも使用しなければいけません。

では早速、実装していきます。

iOS14以前のバージョンに対応した場合の実装

※長いので一部抜粋

ViewController
import UIKit
import CoreLocation

final class ViewController: UIViewController {

    private var locationManager: CLLocationManager = {
        var locationManager = CLLocationManager()
        locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
        locationManager.distanceFilter = 5
        return locationManager
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        locationManager.requestWhenInUseAuthorization()
        locationManager.delegate = self
    }
}
    // 推奨しているデリゲートメソッド
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        // iOS14以降の場合
        if #available(iOS 14.0, *) {
            if CLLocationManager.locationServicesEnabled() {

                let status = manager.authorizationStatus

                switch status {
                case .authorizedAlways, .authorizedWhenInUse:
                    manager.startUpdatingLocation()

                case .notDetermined:
                    manager.requestWhenInUseAuthorization()

                case .denied:
                    Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                        UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                    }

                case .restricted:
                    Alert.okAlert(vc: self, title: "位置情報サービスの使用を\n許可されていません", message: "何らかの制限が掛かっています")

                default:
                    break
                }
            }else {
                Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
            }
        }
    }
    // 非推奨のデリゲートメソッド
    //iOS14以前の場合
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if CLLocationManager.locationServicesEnabled() {

            switch status {
            case .authorizedAlways, .authorizedWhenInUse:
                manager.startUpdatingLocation()

            case .notDetermined:
                manager.requestWhenInUseAuthorization()

            case .denied:
                Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                }

            case .restricted:
                Alert.okAlert(vc: self, title: "位置情報サービスの使用を\n許可されていません", message: "")

            default:
                break
            }

        }else {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
    }

こんな感じの実装となり、どちらも記述します。
メソッド内は、バージョンの指定以外、ほぼ変わらないですね。

これでiOS14以前のバージョンに対応できました。

エラー処理

どの場面でも大体、エラー処理というものは存在しますが、もちろんCLLocationManagerでも存在します。
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error)

では、どのようにViewController内で記述するのかというと、こんな感じです↓

ViewController
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
    //本来なら、アラートで画面にエラー内容を表示したりする
    print("\((error as NSError).domain)")
}

このデリゲートメソッドはCLLocationManagerが位置情報を取得できなかった時に呼ばれます。
このメソッドを実装していない場合、エラーをスルーしてしまうので要注意です。

様々なエラーがあるので、見てみて下さい↓
CLError

そして、ここからは自分が苦戦した所を紹介します。

苦戦した部分~認証ステータスの取得~

認証ステータスの取得も、先ほど紹介しましたが推奨・非推奨のコードがありましたね。
ここが、かなり厄介?で現在進行形で悩んでいます...。

// 位置情報の認証ステータス取得

// 推奨していない iOS 4.2–14.0
func authorizationStatus() -> CLAuthorizationStatus

// 推奨している iOS14-
var authorizationStatus: CLAuthorizationStatus

まず、この2つのコードを比較すると
推奨していない方はメソッド、推奨している方はプロパティとなっています。

ここがまず、大きな違いですね。

そして、推奨していないfunc authorizationStatus()メソッドは
アプリがバックグラウンド・フォアグラウンド状態関係なく認証ステータスを返してくれますが

今回、iOS14で新しく登場したauthorizationStatusプロパティの方は
フォアグラウンド状態の時の認証ステータスしか返してくれません。

つまり、authorizationStatusプロパティの方はバックグラウンド状態で認証ステータスを変更した場合
フォアグラウンド状態の時の認証ステータスを返すんですね。

じゃあ、この部分の一体どこで苦戦したのかというと

バックグラウンド状態から戻ってきた時にアプリの位置情報サービスの有無をチェックする部分です。

実際に分かりやすいようにコードで説明しますね。
認証ステータスを取得する場合、今までだと下記の実装でした↓

ViewController
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
    }

   // バックグラウンド状態から戻った時に呼ばれるメソッド
   @objc private func willEnterForeground() {
      if CLLocationManager.locationServicesEnabled() {
         // 認証ステータスの取得(非推奨)
         let status = CLLocationManager.authorizationStatus()

         switch status {

         // 許可しない場合
         case .denied:
             Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                 UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
             }

            default:
                break
            }

        }else {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
      }
   }

では次に、推奨しているコードでの実装↓

ViewController
    private var locationManager: CLLocationManager = {
        var locationManager = CLLocationManager()
        locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
        locationManager.distanceFilter = 5
        return locationManager
    }()

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
    }

   // バックグラウンド状態から戻った時に呼ばれるメソッド
   @objc private func willEnterForeground() {
      if CLLocationManager.locationServicesEnabled() {
         // 認証ステータスの取得(推奨)
         let status = locationManager.authorizationStatus

         switch status {

         // 許可しない場合
         case .denied:
             Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                 UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
             }

            default:
                break
            }

        }else {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
      }
   }

さほど変化はないんですが、これをビルドしてみると違いが分かります。

まず、どちらも起動時に認証ステータスが .denidである場合はちゃんとアラートを表示します。
ですが、バックグラウンド状態で .denid > .restrictedに変更してフォアグラウンド状態に戻すとこうなります↓
ezgif.com-gif-maker4.gif

本来、許可しない(.denid)で表示されるアラートが
Appの使用中は許可(.authorizedWhenInUse)でも表示されてしまいました。

この不具合が、発生してしまうので仕方なく非推奨のfunc authorizationStatus()メソッド
を呼んでいる現状なんですね。

authorizationStatusプロパティはどう使うんだろう?

色々、試行錯誤していきながら調べながら自分はこういう結論に至りました。

そもそも、iOS14で登場したauthorizationStatusプロパティは

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager)ありきで使用してこのメソッド内で使っていく。

じゃあ、そもそも非推奨のfunc authorizationStatus()メソッドは使わなくても良いんじゃない?

そのメソッドはCLLocationManagerクラスの初期化及び、バックグラウンド・フォアグラウンド状態関係なく認証ステータスが変更されたら呼ばれるデリゲートメソッドだし、willEnterForeground()は必要ないのでは?↓

// バックグラウンド時に戻った時に呼ばれるメソッド
@objc private func willEnterForeground()

と思う方がいらっしゃると思います。

では、このような場面ではどうでしょうか?

例えば、ユーザーに対して位置情報の許可をリクエストして
アラートが表示され、許可しないを選択したとします。

今までの流れからいくと、このようなアラートが表示されて設定アプリに画面遷移します。

これで、設定アプリに画面遷移したはいいけど何もしないで戻った時
func willEnterForeground()を呼ばない場合どうでしょうか?

呼ばない場合は、何もチェックされません。

もちろん、認証ステータスが変更されていないので
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager)
も呼ばれません。

以上の理由からfunc willEnterForeground()を使用していますし、
非推奨のfunc authorizationStatus()メソッドも使用している現状なんですね。

この部分の改善方法を色々、調べたんですが最新の記事でも非推奨のfunc authorizationStatus()メソッドを使用していて頭の中は???でいっぱいでした。

この解決方法をご存知の方は、コメントで教えて下さると有り難いです。

おわり

という事で三部構成という長い記事になってしまいましたが、今回で完結です。
位置情報を取得するだけで、ここまで配慮しなくてはいけないというのはびっくりですね。

もしかしたら、ここは全然違うよー!という部分があるかもしれません。
そういう場合は遠慮なく、コメントして下さると有り難いです。

※おわりで言うのもなんですが、アラートのタイトルなどで何度も同じことを書くのは
マジックナンバー扱いになってしまい駄目なのですが、これは後日、記事としてまとめて投稿します。

ここまでのソースコードは下記にて載せておきますので良かったら参考にしてみて下さい↓

最後まで読んで下さり、ありがとうございました。

※追記

extentionファイルを作って拡張すれば、ViewControllerのボリュームを抑えることも出来ます↓

CLLocationManager+
import UIKit
import CoreLocation

extension CLLocationManager {
    func alertStatusIfNeeded(vc: UIViewController) {
        if #available(iOS 14.0, *) {
            if CLLocationManager.locationServicesEnabled() {
                let status = self.authorizationStatus

                switch status {
                case .notDetermined:
                    self.requestWhenInUseAuthorization()

                case .restricted:
                    // アラート表示したり

                case .denied:
                    // アラート表示したり

                case .authorizedAlways:
                    self.startUpdatingLocation()

                case .authorizedWhenInUse:
                    self.startUpdatingLocation()

                default:
                    break
                }
            }else {
                // アラート表示したり
            }
        }
    }

    func alertStatusIfNeededUnderiOSVer(vc: UIViewController) {
        if CLLocationManager.locationServicesEnabled() {
            let status = CLLocationManager.authorizationStatus()

            switch status {
            case .notDetermined:
                self.requestWhenInUseAuthorization()

            case .restricted:
                // アラート表示したり

            case .denied:
                // アラート表示したり

            case .authorizedAlways:
                self.startUpdatingLocation()

            case .authorizedWhenInUse:
                self.startUpdatingLocation()

            default:
                break
            }
        }else {
           // アラート表示したり
    }

    func alertStatusIfNeededBackground(vc: UIViewController) {
        if !CLLocationManager.locationServicesEnabled() {
            // アラート表示したり
            return
        }

        let status = CLLocationManager.authorizationStatus()

        if status == .denied {
            // アラート表示したり
        }
    }
}

ViewControllerではこんな感じになります↓
※一部抜粋

ViewController
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        manager.alertStatusIfNeeded(vc: self)
    }

ソースコード

こちらにもあげてます。

ViewController
import UIKit
import CoreLocation

final class ViewController: UIViewController {

    private var locationManager: CLLocationManager = {
        var locationManager = CLLocationManager()
        locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
        locationManager.distanceFilter = 5
        return locationManager
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        locationManager.requestWhenInUseAuthorization()
        locationManager.delegate = self
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    @objc private func willEnterForeground() {
        if CLLocationManager.locationServicesEnabled() {

            let status = CLLocationManager.authorizationStatus()

            switch status {
            case .denied:
                Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                }

            default:
                break
            }

        }else {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
    }
}

extension ViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        if #available(iOS 14.0, *) {
            if CLLocationManager.locationServicesEnabled() {

                let status = manager.authorizationStatus

                switch status {
                case .authorizedAlways, .authorizedWhenInUse:
                    manager.startUpdatingLocation()

                case .notDetermined:
                    manager.requestWhenInUseAuthorization()

                case .denied:
                    Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                        UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                    }

                case .restricted:
                    Alert.okAlert(vc: self, title: "位置情報サービスの使用を\n許可されていません", message: "")

                default:
                    break
                }
            }else {
                Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
            }
        }
    }

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if CLLocationManager.locationServicesEnabled() {

            switch status {
            case .authorizedAlways, .authorizedWhenInUse:
                manager.startUpdatingLocation()

            case .notDetermined:
                manager.requestWhenInUseAuthorization()

            case .denied:
                Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
                }

            case .restricted:
                Alert.okAlert(vc: self, title: "位置情報サービスの使用を\n許可されていません", message: "")

            default:
                break
            }

        }else {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let gps = manager.location?.coordinate else {
            return
        }
        manager.stopUpdatingLocation()
        let lat = gps.latitude
        let lng = gps.longitude
        print("経度:\(String(describing: lat)), 緯度:\(String(describing: lng))")
    }
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("\((error as NSError).domain)")
    }
}
Alert
import UIKit

final class Alert {
    static func okAlert(vc: UIViewController,title: String, message: String, handler: ((UIAlertAction) -> Void)? = nil) {
        let okAlertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        okAlertVC.addAction(UIAlertAction(title: "OK", style: .default, handler: handler))
        vc.present(okAlertVC, animated: true, completion: nil)
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIでもSize Classで縦横のレイアウトを切り替える

はじめに

今さらながら、初めてSwiftUIで新規アプリを開発する機会があり、
縦向きと横向きでレイアウトを変える必要があったのでその方法について書きます(`・ω・´)

サイズクラスについて

画面サイズは、高さと幅(height and width)とサイズ(regular and compact)の組み合わせで定義されます。

ざっくりと以下のような感じです。

width height デバイスの種類と向き
regular regular iPadの縦向き・横向き
compact regular iPhoneの縦向き
regular compact 大きいiPhone(Plus / Max / XR and 11など)の横向き
compact compact iPhoneの横向き

ここでは詳細は割愛しますが、HIGのAdaptivity and Layout の「Size Classes」のところに詳細書いてあります( ̄・ω・ ̄)

SwiftUIのhorizontalSizeClass, verticalSizeClassの定義について

以下のようにEnvironmentValuesにあらかじめ定義されている
horizontalSizeClass, verticalSizeClassを使用することでサイズクラスを取得できます。

SwiftUI
@available(iOS 13.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
extension EnvironmentValues {

    /// The horizontal size class of this environment.
    @available(macOS, unavailable)
    @available(tvOS, unavailable)
    @available(watchOS, unavailable)
    public var horizontalSizeClass: UserInterfaceSizeClass?

    /// The vertical size class of this environment.
    @available(macOS, unavailable)
    @available(tvOS, unavailable)
    @available(watchOS, unavailable)
    public var verticalSizeClass: UserInterfaceSizeClass?
}

↑ UserInterfaceSizeClassは、compactregularのケースを持つenumです。

SwiftUIのViewでサイズクラスを考慮してレイアウトを変更する

ここからが本題です( ̄ω ̄)
サイズクラスを取得して、iPhoneで横向きの場合はレイアウトを変えてみます。

まずは、何も考慮しない場合の画面

ボタン3つとイメージ1つ、その下にまたボタンが1つある以下にもサンプルな画面です。
(いらすとやにONE PIECEのイラストが追加されていて驚きました...!)

ContentView.swift
import SwiftUI

struct ContentView: View {

    var body: some View {
        VStack {
            VStack(alignment: .center, spacing: 16.0, content: {
                self.roundedButton(title: "ぼたん1", backgroundColor: .red) {
                    print("ぼたん1タップ!")
                }
                self.roundedButton(title: "ぼたん2", backgroundColor: .yellow) {
                    print("ぼたん2タップ!")
                }
                self.roundedButton(title: "ぼたん3", backgroundColor: .blue) {
                    print("ぼたん3タップ!")
                }
            })
            .padding([.top], 16.0)
            .padding([.leading, .trailing], 32.0)

            Spacer()

            Image("enel")
                .resizable()
                .frame(minWidth: 200, minHeight: 200)
                .aspectRatio(contentMode: .fit)

            Spacer()

            self.roundedButton(title: "ぼとむぼたん", backgroundColor: .orange) {
                print("ぼとむぼたんタップ!")
            }
            .padding([.top], 0)
            .padding([.leading, .bottom, .trailing], 32.0)
        }
    }
}

private extension ContentView {

    /// 角丸のボタン
    func roundedButton(title: String, textColor: Color = .white, backgroundColor: Color,  action: @escaping() -> Void) -> some View {
        Button(action: {
            action()
        }, label: {
            Text(title)
                .frame(maxWidth: 440.0, minHeight: 44.0)
                .font(Font.subheadline.weight(.bold))
                .foregroundColor(textColor)
                .background(backgroundColor)
                .cornerRadius(8.0)
        })
    }
}
縦向き 横向き
portrait_1.png landscape_1.png

エネルのイメージの最低サイズが決まっているので、上下のボタンが見切れてしまいました(´・ω・`)

サイズクラスを考慮して、横向きの画面のレイアウトを変更する

horizontalSizeClass, verticalSizeClassを取得するために、SwiftUIのViewにEnvironmentを追加します。

ContentView.swift
import SwiftUI

struct ContentView: View {

    @Environment(\.horizontalSizeClass) var hSizeClass // 追加1
    @Environment(\.verticalSizeClass) var vSizeClass   // 追加2

    var body: some View {
        // ...
    }
}

毎回if文を作るのはしんどそうなので、horizontalSizeClassとverticalSizeClassでイニシャライズするenumを定義します

DeviceTraitStatus.swift
import SwiftUI

enum DeviceTraitStatus {
    case wRhR
    case wChR
    case wRhC
    case wChC

    init(hSizeClass: UserInterfaceSizeClass?, vSizeClass: UserInterfaceSizeClass?) {

        switch (hSizeClass, vSizeClass) {
        case (.regular, .regular):
            self = .wRhR
        case (.compact, .regular):
            self = .wChR
        case (.regular, .compact):
            self = .wRhC
        case (.compact, .compact):
            self = .wChC
        default:
            self = .wChR
        }
    }
}

先ほどの画面を修正していきます。
横向きの場合は、赤いボタンと黄色いボタンを横並びにして画面に収まるようにしてみます!

ContentView.swift
import SwiftUI

struct ContentView: View {

    @Environment(\.horizontalSizeClass) var hSizeClass
    @Environment(\.verticalSizeClass) var vSizeClass

    var body: some View {
        VStack {
            let deviceTraitStatus = DeviceTraitStatus(hSizeClass: self.hSizeClass, vSizeClass: self.vSizeClass)
            switch deviceTraitStatus {
            case .wRhR, .wChR:
                self.buttonsOnPortrait
            case .wRhC, .wChC:
                self.buttonsOnLandscape
            }

            // ...
        }
    }
}

private extension ContentView {

    var button1: some View {
        self.roundedButton(title: "ぼたん1", backgroundColor: .red) {
            print("ぼたん1タップ!")
        }
    }

    var button2: some View {
        self.roundedButton(title: "ぼたん2", backgroundColor: .yellow) {
            print("ぼたん2タップ!")
        }
    }

    var button3: some View {
        self.roundedButton(title: "ぼたん3", backgroundColor: .blue) {
            print("ぼたん3タップ!")
        }
    }

    /// iPhoneの縦表示またはiPadの場合のボタン3つのレイアウト
    ///
    /// - Note: iPadの場合は、縦横ともに画面に収まるのでレイアウト変えない。
    var buttonsOnPortrait: some View {
        VStack(alignment: .center, spacing: 16.0, content: {
            self.button1
            self.button2
            self.button3
        })
        .padding([.top], 16.0)
        .padding([.leading, .trailing], 32.0)
    }

    /// iPhoneの横表示の場合のボタン3つのレイアウト
    var buttonsOnLandscape: some View {
        VStack {
            HStack {
                self.button1
                Spacer()
                self.button2

            }
            .padding([.leading, .trailing], 32.0)
            .padding([.bottom], 4.0)

            self.button3
        }
        .padding([.top], 16.0)
    }

    /// 角丸のボタン
    func roundedButton(title: String, textColor: Color = .white, backgroundColor: Color,  action: @escaping() -> Void) -> some View {
        // ...
    }
}
修正後の縦向き 修正後の横向き
portrait_2.png landscape_2.png

画面回転したら、レイアウトが変更されるようになりました\(。・ω・。)/

さいごに

SwiftUIはまだまだ勉強中なので、もっと良いやり方などあればコメントをお願いしますm(_ _)m

ソースコードは、GitHubにリポジトリを作ってpushしました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ショートカットを利用したAmazon Musicのプレイリスト自動再生

プロローグ

iPhoneのショートカットの音楽再生および操作はデフォルトの音楽アプリ"ミュージック"のみしか対応していない。そのため、今主流となりつつあるSpotify・LINE music・Apple Musicなどのストリーミングサービスサードパーティの音楽アプリを操作することができるないので不便を感じていた。この記事にたどり着いた皆さんもきっとそう感じているに違いない。

Bluetoothイヤホン接続からの自動化を完成させたい

特に不便を感じていたのは自作の音楽自動再生ショートカットである。Bluetoothイヤホンの接続をトリガーとしてAmazon Musicを開くというもので、目標はプレイリストの再生まで持っていきたい。しかし、前述の通りアプリの操作ができないため、アプリを開くところ止まりで音楽の再生まで不可能であった。が、最近Amazon Musicにおいて、プレイリストの再生まで持っていく方法が判明した。

ショートカットを作成する

1. プレイリストの共有リンクを取得する

Amazon Musicで作成したプレイリストの詳細からプレイリスト共有用のリンクを取得する。
プレイリスト右側の… > シェアする > リンクをコピー

2. リンクをいじる

ここが肝となる工程。
取得したリンクはこのような構造になっている。
https://music.amazon.co.jp/user-playlists/@@@@@@@@@@?ref=dm_sh_~~~~~~~

このリンクの?以降は不要なので全て削除する。

そして、?に続いてdo=playを書き込む。
https://music.amazon.co.jp/user-playlists/@@@@@@@@@@?do=play

これで準備完了。先ほどの未加工のリンクを開いた場合、Amazon Musicのアプリが開かれ、プレイリストの画面に到達するだけで終わる。しかし、このリンクを叩けば、プレイリストのページが開かれるのと同時に楽曲の再生が行われる。

ただし、ここでリンクを開くときのブラウザーに制限がある
私のiPhone12の環境ではChrome, Safari, Openerの3種類を試したが、結論から言うとOpenerがベスト。
まず、Chromeだがこれは微妙。Chrome上でプレイリストが開かれ、"Amazon Musicで再生する"と表示されるだけなのでタプしなければいけない手間が増えるのと、自動で再生されないという2つの弱点がある。
次にSafiri。これは自動でAmazon Musicのアプリを開きプレイリストを再生するところまではうまく行った。しかし、Amazon Musicを既に使用中である場合、現在の曲の再生を中止してプレイリストの曲を強制的に再生させることができない。
最後にOpener。これはOpener内の設定でAmazon MusicアプリにURLを渡すように自動設定を組んでやると、URLを叩いてからノータッチでプレイリストの自動再生ができる。さらにSafariにはできなかったプレイリストの強制再生ができるのでこちらの方をお勧めする。

2. ショートカットを組む

2-1. OpenerでURLを開く

作成したリンクをOpenerで開くショートカットを単一で作成しておく。

2-2. オートメーションで自動化する

オートメーションで使用しているBluetoothイヤホンが接続されたら、先ほど作成されたショートカットが起動するように設定する。

3. 動作確認

イメージ.gif

OpenerでAmazonMusicのリンクを開くがタップされているように見えるが、これはアプリ側で自動的に行われているものである。Bluetoothイヤホンを起動させてから音楽再生までに行う操作は、オートメーションの実行許可のタップのみである。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む