バックエンドの人がNativePHPの力を借りて初めてモバイルアプリを作ってみたの巻(特別編)

Date:

Share post:

前回の投稿では、タイムレコーダー画面と打刻 API、手動座標での半径判定までを確認しました。

手動座標はデモや失敗パターンの再現には便利ですが、せっかくモバイルアプリを作っているので、スマホならではの GPS で位置を取りたい気持ちがあります。SuperNative の価値を示すうえでも、ネイティブ側から位置を取るところまで行きたいところです。

Marketplace に nativephp/mobile-geolocation はありますが、有料ですので、今回は、打刻に必要な「いまの位置を 1 回取る」だけに絞って、自作のワンショット GPS プラグイン local/simple-geolocation を実装し、手動座標を OFF にしたまま出勤できるところまで進めます。

ただ、自作プラグインになると Android 側は Kotlin にも触れることになります。普段の PHP からは少し離れる内容なので、本編とは切り分けて 特別編 としてまとめます。

1)今回の範囲と制限事項

自前プラグインでの動作範囲と制限事項を以下に記載します。

動作範囲(できること)制限事項(やらないこと)
現在地を 1 回取得し、Event で PHP に返す常時トラッキング・バックグラウンド追従
Android / iOS で同じ Bridge API地図表示
権限ダイアログの表示有料プラグイン相当の機能実装

打刻する際に必要な「現在の位置を取得」だけに絞っています。

2)プラグインのパッケージ構成

配置は packages/simple-geolocation で、Composer の path リポジトリとして読み込みます。

Composer のパッケージ名は、通常 ベンダー名/パッケージ名 の形になります。社内向けなら 会社名/… のように社名をベンダーにすることが多いですが、今回は検証ですので、社名には依存しない local/simple-geolocation にしました。PHP の名前空間もあわせて Local\SimpleGeolocation です。

  • PHP : Facade / Pending / LocationReceived Event
  • Android : SimpleGeolocationFunctions.kt(LocationManager)
  • iOS : SimpleGeolocationFunctions.swift(CLLocationManager)
  • マニフェスト : nativephp.json(権限・Bridge・Event の定義)

以下はパッケージツリーとなります。

プラグインの処理の流れはシンプルです。打刻ボタンを押すと、PHP からネイティブへ「現在の位置を取って」と頼み、結果が戻ってきてから、前回と同じ半径判定と API 送信に入ります。

  1. Timecard(PHP)が位置取得を要求する
  2. Bridge(SimpleGeolocation.GetCurrentPosition)経由でネイティブへ渡す
  3. Android は Kotlin、iOS は Swift が GPS を取る
  4. 結果を LocationReceived Event として PHP に返す
  5. 半径判定のあと、問題なければ打刻 API へ送る

プラグイン構成図の記述で示すとすると、次のとおりです。

Timecard(PHP)→ Bridge(SimpleGeolocation.GetCurrentPosition)→ Kotlin / Swift → LocationReceived Event → 半径判定 → 打刻 API

前回と違うのは、位置の取り方だけです。判定や API 連携はそのまま使えます。

3)プラグインソースの説明

composer.json は Composer 用のパッケージ定義です。パッケージ名、PHP / NativePHP への依存、オートロード、ServiceProvider と nativephp.json の場所をここで指定します。

packages/simple-geolocation/composer.json

{
    "name": "local/simple-geolocation",
    "description": "Minimal one-shot GPS plugin for NativePHP Mobile SuperNative (blog Part3)",
    "type": "nativephp-plugin",
    "version": "1.0.0",
    "license": "MIT",
    "authors": [
        {
            "name": "Local",
            "email": "dev@example.com"
        }
    ],
    "require": {
        "php": "^8.2",
        "nativephp/mobile": "^4.0"
    },
    "autoload": {
        "psr-4": {
            "Local\\SimpleGeolocation\\": "src/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "Local\\SimpleGeolocation\\SimpleGeolocationServiceProvider"
            ]
        },
        "nativephp": {
            "manifest": "nativephp.json"
        }
    },
    "minimum-stability": "stable"
}

nativephp.json は NativePHP プラグインのマニフェストです。Bridge 名( SimpleGeolocation.GetCurrentPosition )、Android / iOS の実装クラス、位置情報の権限、返す Event、ServiceProvider を宣言します。ここがプラグインの入口です。

packages/simple-geolocation/nativephp.json

{
    "name": "local/simple-geolocation",
    "version": "1.0.0",
    "description": "Minimal one-shot GPS for NativePHP Mobile SuperNative screens",
    "namespace": "SimpleGeolocation",
    "keywords": ["geolocation", "gps", "location"],
    "category": "utilities",
    "license": "MIT",
    "pricing": {
        "type": "free"
    },
    "platforms": ["android", "ios"],
    "bridge_functions": [
        {
            "name": "SimpleGeolocation.GetCurrentPosition",
            "android": "com.local.plugins.simplegeolocation.SimpleGeolocationFunctions.GetCurrentPosition",
            "ios": "SimpleGeolocationFunctions.GetCurrentPosition",
            "android_params": ["activity"],
            "description": "Request a one-shot GPS fix and dispatch LocationReceived"
        }
    ],
    "android": {
        "min_version": 26,
        "permissions": [
            "android.permission.ACCESS_COARSE_LOCATION",
            "android.permission.ACCESS_FINE_LOCATION"
        ],
        "repositories": [],
        "dependencies": {
            "implementation": []
        }
    },
    "ios": {
        "min_version": "15.0",
        "info_plist": {
            "NSLocationWhenInUseUsageDescription": "出退勤の打刻時に、会社所在地との距離を確認するために位置情報を使用します。"
        },
        "dependencies": {
            "swift_packages": [],
            "pods": []
        }
    },
    "events": [
        "Local\\SimpleGeolocation\\Events\\LocationReceived"
    ],
    "service_provider": "Local\\SimpleGeolocation\\SimpleGeolocationServiceProvider"
}

SimpleGeolocation.php はモバイルアプリ側から SimpleGeolocation:: 〜 と書くための Facade です。Timecard はこのクラス経由で位置取得を呼び出します。

packages/simple-geolocation/src/Facades/SimpleGeolocation.php

<?php

namespace Local\SimpleGeolocation\Facades;

use Illuminate\Support\Facades\Facade;
use Local\SimpleGeolocation\PendingGetCurrentPosition;

/**
 * @method static PendingGetCurrentPosition getCurrentPosition(bool $fineAccuracy = true)
 *
 * @see \Local\SimpleGeolocation\SimpleGeolocation
 */
class SimpleGeolocation extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return \Local\SimpleGeolocation\SimpleGeolocation::class;
    }
}

SimpleGeolocation.php は Facade の実体です。getCurrentPosition() はすぐ座標を返さず、依頼用の PendingGetCurrentPosition を返します。

さきほどの Facades 配下とファイル名が同じで、どちらも SimpleGeolocation.php ですが、Facades 配下は呼び出し用、こちらは実体です。名前空間が違うので別クラスです。

packages/simple-geolocation/src/SimpleGeolocation.php

<?php

namespace Local\SimpleGeolocation;

class SimpleGeolocation
{
    /**
     * Request a one-shot GPS fix.
     * Listen with ->locationReceived() or #[On(LocationReceived::class)].
     */
    public function getCurrentPosition(bool $fineAccuracy = true): PendingGetCurrentPosition
    {
        return (new PendingGetCurrentPosition)->fineAccuracy($fineAccuracy);
    }
}

PendingGetCurrentPosition.php は位置取得の依頼オブジェクトです。 locationReceived(…) でコールバックを登録し、デストラクタ(または get() )で NativePHP 本体の nativephp_call を呼び出して Bridge に渡します。結果の受け取り口は LocationReceived です。

packages/simple-geolocation/src/PendingGetCurrentPosition.php

<?php

namespace Local\SimpleGeolocation;

use Illuminate\Support\Str;
use Native\Mobile\Concerns\HandlesNativeCallbacks;
use Local\SimpleGeolocation\Events\LocationReceived;

/**
 * @method $this locationReceived(\Closure|array|string $callback)
 */
class PendingGetCurrentPosition
{
    use HandlesNativeCallbacks;

    protected ?string $id = null;

    protected string $eventClass = LocationReceived::class;

    protected bool $fineAccuracy = true;

    protected bool $started = false;

    public function id(string $id): self
    {
        $this->id = $id;

        return $this;
    }

    public function getId(): string
    {
        if ($this->id === null) {
            $this->id = (string) Str::uuid();
        }

        return $this->id;
    }

    public function fineAccuracy(bool $fine = true): self
    {
        $this->fineAccuracy = $fine;

        return $this;
    }

    public function get(): bool
    {
        if ($this->started) {
            return false;
        }

        $this->started = true;

        if (! function_exists('nativephp_call')) {
            return false;
        }

        nativephp_call('SimpleGeolocation.GetCurrentPosition', json_encode([
            'id' => $this->getId(),
            'event' => $this->eventClass,
            'fineAccuracy' => $this->fineAccuracy,
        ]));

        return true;
    }

    public function __destruct()
    {
        if (! $this->started) {
            $this->get();
        }
    }
}

LocationReceived.php はネイティブから戻ってきた結果を表す Event です。成功可否、緯度・経度、精度、エラーメッセージ、リクエスト ID を持ちます。Timecard の locationReceived コールバックに渡る $event がこれです。

packages/simple-geolocation/src/Events/LocationReceived.php

<?php

namespace Local\SimpleGeolocation\Events;

use Illuminate\Foundation\Events\Dispatchable;

class LocationReceived
{
    use Dispatchable;

    public function __construct(
        public bool $success,
        public ?float $latitude = null,
        public ?float $longitude = null,
        public ?float $accuracy = null,
        public ?string $error = null,
        public ?string $id = null,
    ) {}
}

SimpleGeolocationServiceProvider.php は Laravel の ServiceProvider です。SimpleGeolocation をコンテナに singleton 登録します。composer.json / nativephp.json の両方から参照されます。

packages/simple-geolocation/src/SimpleGeolocationServiceProvider.php

<?php

namespace Local\SimpleGeolocation;

use Illuminate\Support\ServiceProvider;

class SimpleGeolocationServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(SimpleGeolocation::class);
    }

    public function boot(): void
    {
        //
    }
}

SimpleGeolocationFunctions.kt は Android 側の Bridge 実装です。GetCurrentPosition が PHP からの呼び出しを受け、権限確認のあと LocationManager で位置を取得します。成功/失敗は LocationReceived 相当の Event として PHP に返します。

主にエミュレータで位置の更新が来にくいことへの対策として、次の工夫を入れています。実機でも屋内や起動直後などでは役立ちますが、打刻のように半径の狭い用途では、キャッシュの新しさにも注意が必要です。

  1. 数分以内の最終位置キャッシュがあればすぐ返す(geo fix 直後に効く)
  2. 複数プロバイダで購読する(GPS / NETWORK / PASSIVE など)
  3. タイムアウト時も last-known があればフォールバックする
packages/simple-geolocation/resources/android/SimpleGeolocationFunctions.kt

package com.local.plugins.simplegeolocation

import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Criteria
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.nativephp.mobile.bridge.BridgeFunction
import com.nativephp.mobile.utils.NativeActionCoordinator
import org.json.JSONObject

/**
 * Minimal one-shot GPS bridge for SuperNative screens.
 *
 * Emulators often keep a last-known fix but never deliver a fresh GPS
 * update. Prefer a recent cache, listen on multiple providers, and fall
 * back to any last-known location on timeout.
 */
object SimpleGeolocationFunctions {

    class GetCurrentPosition(private val activity: FragmentActivity) : BridgeFunction {
        override fun execute(parameters: Map<String, Any>): Map<String, Any> {
            val id = parameters["id"] as? String
            val event = parameters["event"] as? String
                ?: "Local\\SimpleGeolocation\\Events\\LocationReceived"
            val fineAccuracy = when (val value = parameters["fineAccuracy"]) {
                is Boolean -> value
                is Number -> value.toInt() != 0
                else -> true
            }

            Handler(Looper.getMainLooper()).post {
                ensureHelper(activity).requestLocation(
                    id = id,
                    event = event,
                    fineAccuracy = fineAccuracy,
                )
            }

            return emptyMap()
        }
    }

    private fun ensureHelper(activity: FragmentActivity): LocationHelperFragment {
        val tag = "SimpleGeolocationHelper"
        val existing = activity.supportFragmentManager.findFragmentByTag(tag) as? LocationHelperFragment
        if (existing != null) {
            return existing
        }

        val helper = LocationHelperFragment()
        activity.supportFragmentManager.beginTransaction()
            .add(helper, tag)
            .commitNowAllowingStateLoss()

        return helper
    }
}

class LocationHelperFragment : Fragment() {
    private var pendingId: String? = null
    private var pendingEvent: String = "Local\\SimpleGeolocation\\Events\\LocationReceived"
    private var pendingFine: Boolean = true
    private var locationListener: LocationListener? = null
    private var settled = false
    private val timeoutHandler = Handler(Looper.getMainLooper())

    private val permissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { result ->
        val granted = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
            result[Manifest.permission.ACCESS_COARSE_LOCATION] == true

        if (granted) {
            fetchLocation()
        } else {
            dispatchError("位置情報の利用が許可されていません。端末の設定から位置情報を許可してください。")
        }
    }

    fun requestLocation(id: String?, event: String, fineAccuracy: Boolean) {
        pendingId = id
        pendingEvent = event
        pendingFine = fineAccuracy
        settled = false

        val fine = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION)
        val coarse = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION)

        if (fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED) {
            fetchLocation()
            return
        }

        permissionLauncher.launch(
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.ACCESS_COARSE_LOCATION,
            )
        )
    }

    @SuppressLint("MissingPermission")
    private fun fetchLocation() {
        val context = context ?: run {
            dispatchError("位置情報を取得する準備ができていません。")
            return
        }

        val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
        if (manager == null) {
            dispatchError("この端末では位置情報を取得できません。")
            return
        }

        // Fresh enough cache → return immediately (common on emulators after geo fix).
        bestLastKnownLocation(manager)?.let { last ->
            if (locationAgeMs(last) <= FRESH_CACHE_MS) {
                dispatchSuccess(last)
                return
            }
        }

        val providers = preferredProviders(manager)
        if (providers.isEmpty()) {
            bestLastKnownLocation(manager)?.let {
                dispatchSuccess(it)
                return
            }
            dispatchError("現在地を取得できませんでした。端末の位置情報をオンにして、もう一度お試しください。")
            return
        }

        val listener = object : LocationListener {
            override fun onLocationChanged(location: Location) {
                settleSuccess(manager, location)
            }

            @Deprecated("Deprecated in Java")
            override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}

            override fun onProviderEnabled(provider: String) {}

            override fun onProviderDisabled(provider: String) {}
        }

        locationListener = listener

        try {
            for (provider in providers) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                    manager.getCurrentLocation(provider, null, context.mainExecutor) { location ->
                        if (location != null) {
                            settleSuccess(manager, location)
                        }
                    }
                }

                manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
            }
        } catch (e: Exception) {
            cleanupListener(manager)
            bestLastKnownLocation(manager)?.let {
                dispatchSuccess(it)
                return
            }
            dispatchError(e.message ?: "位置情報の取得に失敗しました。")
            return
        }

        timeoutHandler.postDelayed({
            if (settled) {
                return@postDelayed
            }

            cleanupListener(manager)
            bestLastKnownLocation(manager)?.let {
                dispatchSuccess(it)
                return@postDelayed
            }

            dispatchError("位置情報の取得がタイムアウトしました。電波状況を確認して、もう一度お試しください。")
        }, TIMEOUT_MS)
    }

    private fun preferredProviders(manager: LocationManager): List<String> {
        val criteria = Criteria().apply {
            accuracy = if (pendingFine) Criteria.ACCURACY_FINE else Criteria.ACCURACY_COARSE
            powerRequirement = Criteria.POWER_HIGH
        }

        val ordered = linkedSetOf<String>()
        manager.getBestProvider(criteria, true)?.let { ordered.add(it) }

        listOf(
            LocationManager.GPS_PROVIDER,
            LocationManager.NETWORK_PROVIDER,
            LocationManager.PASSIVE_PROVIDER,
        ).forEach { name ->
            if (manager.isProviderEnabled(name) || manager.allProviders.contains(name)) {
                ordered.add(name)
            }
        }

        manager.getProviders(true).forEach { ordered.add(it) }

        return ordered.filter { provider ->
            try {
                manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER
            } catch (_: Exception) {
                false
            }
        }
    }

    @SuppressLint("MissingPermission")
    private fun bestLastKnownLocation(manager: LocationManager): Location? {
        val providers = try {
            manager.getProviders(true).ifEmpty { manager.allProviders }
        } catch (_: Exception) {
            manager.allProviders
        }

        return providers.mapNotNull { provider ->
            try {
                manager.getLastKnownLocation(provider)
            } catch (_: Exception) {
                null
            }
        }.maxWithOrNull(compareBy<Location> { it.time }.thenByDescending { it.accuracy })
    }

    private fun locationAgeMs(location: Location): Long {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            val ageElapsed = android.os.SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos
            (ageElapsed / 1_000_000L).coerceAtLeast(0L)
        } else {
            (System.currentTimeMillis() - location.time).coerceAtLeast(0L)
        }
    }

    private fun settleSuccess(manager: LocationManager, location: Location) {
        if (settled) {
            return
        }
        settled = true
        cleanupListener(manager)
        dispatchSuccess(location)
    }

    private fun cleanupListener(manager: LocationManager) {
        timeoutHandler.removeCallbacksAndMessages(null)
        locationListener?.let {
            try {
                manager.removeUpdates(it)
            } catch (_: Exception) {
            }
        }
        locationListener = null
    }

    private fun dispatchSuccess(location: Location) {
        settled = true
        val payload = JSONObject()
            .put("success", true)
            .put("latitude", location.latitude)
            .put("longitude", location.longitude)
            .put("accuracy", location.accuracy.toDouble())
            .put("error", JSONObject.NULL)
            .put("id", pendingId ?: JSONObject.NULL)

        NativeActionCoordinator.dispatchEvent(
            requireActivity(),
            pendingEvent,
            payload.toString(),
        )
    }

    private fun dispatchError(message: String) {
        settled = true
        val host = activity ?: return
        val payload = JSONObject()
            .put("success", false)
            .put("latitude", JSONObject.NULL)
            .put("longitude", JSONObject.NULL)
            .put("accuracy", JSONObject.NULL)
            .put("error", message)
            .put("id", pendingId ?: JSONObject.NULL)

        NativeActionCoordinator.dispatchEvent(host, pendingEvent, payload.toString())
    }

    companion object {
        private const val FRESH_CACHE_MS = 5 * 60 * 1000L
        private const val TIMEOUT_MS = 20_000L
    }
}

SimpleGeolocationFunctions.swift は iOS 側の Bridge 実装です。CLLocationManager でワンショット取得( requestLocation() )を行います。未許可なら requestWhenInUseAuthorization() を出し、タイムアウト時は取得済みの location があればそれを返します。権限ダイアログの説明文は nativephp.json の NSLocationWhenInUseUsageDescription です。

packages/simple-geolocation/resources/ios/Sources/SimpleGeolocationFunctions.swift

import CoreLocation
import Foundation

/// Minimal one-shot GPS bridge for SuperNative screens.
enum SimpleGeolocationFunctions {

    class GetCurrentPosition: NSObject, BridgeFunction, CLLocationManagerDelegate {
        private let locationManager = CLLocationManager()
        private var pendingId: String?
        private var pendingEvent: String = "Local\\SimpleGeolocation\\Events\\LocationReceived"
        private var fineAccuracy: Bool = true
        private var started = false
        private var timeoutWorkItem: DispatchWorkItem?

        func execute(parameters: [String: Any]) throws -> [String: Any] {
            pendingId = parameters["id"] as? String
            pendingEvent = parameters["event"] as? String
                ?? "Local\\SimpleGeolocation\\Events\\LocationReceived"
            fineAccuracy = (parameters["fineAccuracy"] as? Bool) ?? true
            started = false

            DispatchQueue.main.async {
                self.locationManager.delegate = self
                self.locationManager.desiredAccuracy = self.fineAccuracy
                    ? kCLLocationAccuracyBest
                    : kCLLocationAccuracyHundredMeters

                switch self.locationManager.authorizationStatus {
                case .authorizedAlways, .authorizedWhenInUse:
                    self.startUpdating()
                case .notDetermined:
                    self.locationManager.requestWhenInUseAuthorization()
                case .denied, .restricted:
                    self.dispatchError("位置情報の利用が許可されていません。端末の設定から位置情報を許可してください。")
                @unknown default:
                    self.dispatchError("位置情報の取得に失敗しました。もう一度お試しください。")
                }
            }

            return [:]
        }

        private func startUpdating() {
            guard !started else { return }
            started = true
            locationManager.requestLocation()

            let work = DispatchWorkItem { [weak self] in
                guard let self else { return }
                if let location = self.locationManager.location {
                    self.dispatchSuccess(location)
                    return
                }
                self.dispatchError("位置情報の取得がタイムアウトしました。電波状況を確認して、もう一度お試しください。")
            }
            timeoutWorkItem = work
            DispatchQueue.main.asyncAfter(deadline: .now() + 20, execute: work)
        }

        func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
            switch manager.authorizationStatus {
            case .authorizedAlways, .authorizedWhenInUse:
                startUpdating()
            case .denied, .restricted:
                dispatchError("位置情報の利用が許可されていません。端末の設定から位置情報を許可してください。")
            default:
                break
            }
        }

        func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
            timeoutWorkItem?.cancel()
            guard let location = locations.last else {
                dispatchError("現在地を取得できませんでした。端末の位置情報をオンにして、もう一度お試しください。")
                return
            }

            dispatchSuccess(location)
        }

        private func dispatchSuccess(_ location: CLLocation) {
            timeoutWorkItem?.cancel()
            var payload: [String: Any] = [
                "success": true,
                "latitude": location.coordinate.latitude,
                "longitude": location.coordinate.longitude,
                "accuracy": location.horizontalAccuracy,
            ]
            if let pendingId {
                payload["id"] = pendingId
            }

            LaravelBridge.shared.send?(pendingEvent, payload)
        }

        func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
            timeoutWorkItem?.cancel()
            dispatchError(error.localizedDescription)
        }

        private func dispatchError(_ message: String) {
            timeoutWorkItem?.cancel()
            var payload: [String: Any] = [
                "success": false,
                "error": message,
            ]
            if let pendingId {
                payload["id"] = pendingId
            }
            LaravelBridge.shared.send?(pendingEvent, payload)
        }
    }
}

4)プラグインの導入

以下コマンドを実行して、プラグインを追加します。

$ composer config repositories.simple-geolocation path packages/simple-geolocation
$ composer require local/simple-geolocation:@dev
$ php artisan native:plugin:register local/simple-geolocation
$ php artisan native:plugin:list
$ php artisan native:run android --no-interaction --no-tty

プラグイン追加後は、ネイティブコードがアプリに組み込まれるので 再ビルド( native:run )が必要です。PHP / Blade だけ触った場合も、反映は native:run で行います。

$ php artisan native:run android --no-interaction --no-tty

5)Timecard からの呼び出し

NativePHP には、位置情報用の Facade ( Geolocation ) があります。 呼び方は次のような形です。

Geolocation::getCurrentPosition()
    ->locationReceived(function ($event) { ... });

今回の自作プラグインも、モバイルアプリ側からは同じ流れで使えるようにしています。クラス名が SimpleGeolocation に変わるイメージです。

前回のタイムレコーダー画面で「手動座標を使う」のチェックを OFF にすると、この経路になります。

app/NativeComponents/Timecard.php(抜粋)
                ・
                ・
                ・
            SimpleGeolocation::getCurrentPosition(true)
                ->id('timecard-punch')
                ->locationReceived(function ($event) {
                    if (! ($event->success ?? false)) {
                        $this->finishWithError(
                            $this->geolocationErrorMessage($event->error ?? null)
                        );

                        return;
                    }

                    $type = $this->pendingType;

                    if ($type === null) {
                        $this->finishWithError('打刻種別が不明です。');

                        return;
                    }

                    $this->submitPunch(
                        $type,
                        (float) $event->latitude,
                        (float) $event->longitude,
                        isset($event->accuracy) ? (float) $event->accuracy : null,
                    );
                });
                ・
                ・
                ・

ここでのポイントは次の2つです。

  1. SimpleGeolocation::getCurrentPosition(true)
    ここで位置取得を依頼します。true は高精度(GPS)寄りの指定です。

  2. ->locationReceived(function ($event) { … })
    結果が返ってきたときに実行する処理を渡します。まだこの時点では座標は入っていません。

座標はすぐ return されるのではなく、あとからコールバックの $event に入ります。その $event を使って submitPunch(半径判定 → 打刻 API)へ進むところは、前回と同じです。

6)PHP からネイティブへ渡るところ

Timecard の呼び出しの裏側は、次の流れです。

  1. SimpleGeolocation::getCurrentPosition(true)
    ここで位置取得を依頼します。返り値はすぐ座標ではなく、依頼用のオブジェクト PendingGetCurrentPosition です。
    true は高精度(GPS)寄りの指定です。

  2. ->locationReceived(function ($event) { … })
    結果が返ってきたときに実行する処理を、このオブジェクトに登録します。
    まだこの時点では座標は入っていません。

  3. チェーンが終わると、PendingGetCurrentPosition のデストラクタが走る
    自前プラグインの PHP 側から、NativePHP 本体の nativephp_call(‘SimpleGeolocation.GetCurrentPosition’, …) が実行され、Bridge(SimpleGeolocation.GetCurrentPosition)を呼びます。

  4. ネイティブ側の処理が終わると、LocationReceived イベントで結果が戻る
    手順 2 で渡した function ($event) が呼ばれ、$event に成功可否や緯度・経度などが入ります。

  5. $event->latitude / $event->longitude を使って submitPunch(…) へ進む
    ここから先の半径判定と打刻 API は、前回と同じです。

公式の Geolocation も、「依頼用オブジェクト → ネイティブ呼び出し → イベントで受け取る」という考え方は同じです。

7)エミュレータでの検証

ではプラグインの動作をエミュレータで検証するために、まずはエミュレータに「いまいる場所」を入れます。打刻は、この位置が .env の会社座標(COMPANY_LAT / COMPANY_LNG)から半径内かどうかで判定するので、会社座標の近くを指定します。

adb の順番は 経度 → 緯度 で、.env の緯度・経度の並びとは逆です。

adb emu geo fix XXX.XXXXXX XX.XXXXXX

adb shell am force-stop com.native_test.testapp
adb shell am start -n com.native_test.testapp/com.nativephp.mobile.ui.MainActivity

「手動座標を使う」は OFF にして、出勤をタップします。初回の場合、位置情報の権限ダイアログが表示されるので、許可します。

許可後に打刻が成功すると、画面にはデバッグ内容として、距離・精度が表示されます。

Web 打刻一覧画面でも、反映されていることを確認しました。

長くなってしまいましたが、ここまででタイムレコーダーの画面・API・半径判定に加え、端末 GPS まで一通り触れました。
試用ではありますが、スマホならではの機能や実装に触れるよい機会だったと思います。
またアイディアが湧いたときは、お試ししてみたいと思います。

Related articles