javascript

Capacitor: от веба к мобильным приложениям. Часть 2. Как написать свой плагин (Android + iOS)

  • четверг, 19 февраля 2026 г. в 00:00:13
https://habr.com/ru/articles/1000690/

В прошлых частях мы разобрали:

  • зачем выбирать Capacitor;

  • как мигрировать веб-приложение;

  • какие проблемы могут возникнуть при переносе;

Теперь переходим к самому важному месту во всей архитектуре Capacitor — к плагинам.

Именно плагины делают из WebView полноценное мобильное приложение. С ними у Вас появляется доступ к камере, файловой системе, push-уведомлениям, Bluetooth и т.д

В этой статье разберем:

  • как устанавливаются официальные плагины;

  • как работать с community-плагинами;

  • как мигрировать с Cordova;

  • и главное — как написать собственный плагин с нуля на реальном примере отправки SMS.

Официальные плагины Capacitor

Официальные плагины поддерживаются командой Ionic и синхронизированы с мажорными версиями Capacitor.

Пример установки:

npm install @capacitor/camera
npx cap sync

Использование в React:

import { Camera } from '@capacitor/camera';

const photo = await Camera.getPhoto({
  resultType: 'uri'
});

Плюсы:

  • поддерживаются официально;

  • обновляются вместе с Capacitor;

  • как правило хорошо документированы;

Минус по сути один: они покрывают только базовый набор возможностей.

Плагины сообщества

Когда официального решения нет, приходится искать самопальные реализации той или иной нативной фичи.

Установка точно такая же как и у официальных плагинов:

npm install some-capacitor-plugin
npx cap sync

Но в данном случае перед использованием стоит проверить:

  • дату последнего обновления (плагин может безбожно устареть);

  • совместимость с вашей мажорной версией Capacitor;

  • наличие Android и iOS реализации (обычно пишут 2 реализации если это не специфичный SDK);

  • активность репозитория.

Как в официальных плагинах так и в плагинах сообщества зачастую требуются какие-то специфичные разрешения. Какие именно разрешения нужны должны быть описаны в документации к плагину. Например в Android необходимые разрешения для приложения необходимо перечислять в файле AndroidManifest, а в iOS это делается через Xcode или в файле Info.plist

Info.plist
Info.plist

Когда нужен собственный плагин

Свой плагин нужен, если:

  • нет готового решения;

  • требуется специфичный нативный SDK (например Huawei ML kit);

  • нужно инкапсулировать чувствительную логику;

  • платформа ведет себя по-разному (как Android и iOS с SMS).

Отправка SMS — идеальный пример.

В браузере это невозможно.
В WebView — тоже невозможно.
Значит, нужен мост к нативному API.

Создаем SMS-плагин с нуля

Создание плагина покажем на примере написания собственного невероятно скромного плагина по отправке SMS собщений:

const result = await BastionSMS.sendSMS({
  phoneNumber: '+79991234567',
  message: 'Тестовое сообщение'
});

И возвращает:

{
  sentStatus: 'SENT',
  deliveryStatus: 'DELIVERED'
}

(на Android)

Генерация шаблона

npm create @capacitor/plugin

Указываем:

  • имя плагина: в нашем случае BastionSMS

  • npm пакет: @bs-solutions/bastion-sms-plugin

После генерации получаем структуру:

src/
  definitions.ts
  index.ts
  web.ts
android/
ios/
package.json

Проектирование API

В этом файле нам нужно описать все возможные методы которые есть в плагине и отдать наружу интерфейс нашего плагина:

src/definitions.ts

export enum SentStatus {
  SENT = 'SENT',
  FAILED = 'FAILED',
}

export enum DeliveryStatus {
  DELIVERED = 'DELIVERED',
  FAILED = 'FAILED',
}

export type TSmsSendPayload = {
  phoneNumber: string;
  message: string;
};

export type TSmsSendResponse = {
  sentStatus: SentStatus;
  deliveryStatus: DeliveryStatus;
};

export interface IBastionSMSPlugin {
  sendSMS: ({ phoneNumber, message }: TSmsSendPayload) => Promise<TSmsSendResponse>;
}

Важно сразу учитывать:

  • Android умеет возвращать SENT и DELIVERED.

  • iOS не возвращает статус доставки.

  • Пользователь может отменить отправку на iOS.

API должен покрывать все варианты.

Регистрация плагина

Тут все достаточно просто. Используем метод registerPlugin:

src/index.ts

import { registerPlugin } from '@capacitor/core';
import type { BastionSMSPlugin } from './definitions';

export const BastionSMS = registerPlugin<BastionSMSPlugin>('BastionSMS');

Web-реализация

Ее мы писать не будем. Но если бы написали то регистрировали бы как-то так:

const Network = registerPlugin<NetworkPlugin>('Network', {
  web: () => import('./web').then((m) => new m.NetworkWeb()),
});

Перед тем как приступить к нативным реализациям плагина хочется объявить небольшой дисклеймер:

Автор статьи является frontend разработчиком и не обладает в полной мере знаниями нативных платформ. Поэтому код реализаций предлагается принять таким какой он есть. Попросите SourceCraft обяъснить код. Суть статьи не в самом написании нативного кода, а в том чтобы показать процесс создания плагинов.

Android-реализация

Android позволяет отправлять SMS без UI через SmsManager.

Разрешение

Нужно добавить разрешение:

<uses-permission android:name="android.permission.SEND_SMS" />

Также потребуется runtime-разрешение. Например использовать метод getPermissionsAuthorizationStatus из плагина @awesome-cordova-plugins/diagnostic

5.2 JAVA реализация

package наименование пакета вашего плагина (ru.bast.plugins.sms);

import android.app.Activity;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.telephony.SmsManager;
import android.telephony.TelephonyManager;
import androidx.core.content.ContextCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

@CapacitorPlugin(name = "BastionSMSPlugin")
public class BastionSMSPlugin extends Plugin {

    private static final int SMS_REQUEST_CODE = 12345;
    private PluginCall savedCall;

    @PluginMethod
    public void sendSMS(PluginCall call) {
        String phoneNumber = call.getString("phoneNumber");
        String message = call.getString("message");

        if (phoneNumber == null || message == null) {
            call.reject("Phone number and message are required");
            return;
        }

        // Check if device has SIM card and is ready
        if (!hasSimCard()) {
            call.reject("NOSIM");
            return;
        }

        savedCall = call; // Save call for later response

        try {
            SmsManager smsManager = SmsManager.getDefault();

            PendingIntent sentIntent = PendingIntent.getBroadcast(
                getContext(),
                SMS_REQUEST_CODE,
                new Intent("SMS_SENT"),
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
            );

            PendingIntent deliveryIntent = PendingIntent.getBroadcast(
                getContext(),
                SMS_REQUEST_CODE,
                new Intent("SMS_DELIVERED"),
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
            );

            // Register dynamic BroadcastReceiver
            IntentFilter sentFilter = new IntentFilter("SMS_SENT");
            IntentFilter deliveredFilter = new IntentFilter("SMS_DELIVERED");

            ContextCompat.registerReceiver(
                getContext(),
                new BroadcastReceiver() {
                    @Override
                    public void onReceive(Context context, Intent intent) {
                        JSObject result = new JSObject();

                        if ("SMS_SENT".equals(intent.getAction())) {
                            switch (getResultCode()) {
                                case Activity.RESULT_OK:
                                    result.put("sentStatus", "SENT");
                                    break;
                                default:
                                    result.put("sentStatus", "FAILED");
                                    break;
                            }
                        }
                    }
                },
                sentFilter,
                ContextCompat.RECEIVER_EXPORTED
            );
            ContextCompat.registerReceiver(
                getContext(),
                new BroadcastReceiver() {
                    @Override
                    public void onReceive(Context context, Intent intent) {
                        JSObject result = new JSObject();
                        if ("SMS_DELIVERED".equals(intent.getAction())) {
                            switch (getResultCode()) {
                                case Activity.RESULT_OK:
                                    result.put("deliveryStatus", "DELIVERED");
                                    savedCall.resolve(result); // Send final result
                                    getContext().unregisterReceiver(this); // Unregister
                                    break;
                                default:
                                    result.put("deliveryStatus", "FAILED");
                                    savedCall.resolve(result);
                                    getContext().unregisterReceiver(this);
                                    break;
                            }
                        }
                    }
                },
                deliveredFilter,
                ContextCompat.RECEIVER_EXPORTED
            );

            smsManager.sendTextMessage(phoneNumber, null, message, sentIntent, deliveryIntent);
        } catch (Exception e) {
            call.reject("SMS sending failed", e);
        }
    }

    private boolean hasSimCard() {
        TelephonyManager telephonyManager = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE);
        if (telephonyManager == null) {
            return false;
        }

        // Check if device has a SIM card inserted
        int simState = telephonyManager.getSimState();
        return simState != TelephonyManager.SIM_STATE_ABSENT
               && simState != TelephonyManager.SIM_STATE_UNKNOWN;
    }
}

В свое время данная реализация писалась в сжатые сроки и было упущено несколько моментов:

  • нужно добавить BroadcastReceiver для точного отслеживания статусов;

  • нужно обработать несколько SIM;

  • добавить таймаут на доставку потому что ее может не произойти

6. iOS-реализация

На iOS нельзя отправить SMS “тихо”.
Можно только открыть системное окно MFMessageComposeViewController.

Swift-реализация

import Foundation
import Capacitor
import MessageUI
import CoreTelephony

@objc(BastionSMSPlugin)
public class BastionSMSPlugin: CAPPlugin, MFMessageComposeViewControllerDelegate {
    private var call: CAPPluginCall?
    private var currentComposeVC: MFMessageComposeViewController?

    @objc func sendSMS(_ call: CAPPluginCall) {
        self.call = call

        // Check for valid parameters
        guard let phoneNumber = call.getString("phoneNumber"),
              let message = call.getString("message") else {
            call.reject("Phone number and message are required")
            return
        }

        // Check for SIM card availability
        guard hasSimCard() else {
            call.reject("NOSIM")
            return
        }

        // Check if device can send texts
        guard MFMessageComposeViewController.canSendText() else {
            call.reject("SMS not available on this device")
            return
        }

        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }

            let composeVC = MFMessageComposeViewController()
            composeVC.messageComposeDelegate = self
            composeVC.recipients = [phoneNumber]
            composeVC.body = message

            self.currentComposeVC = composeVC

            if let rootVC = UIApplication.shared.keyWindow?.rootViewController {
                rootVC.present(composeVC, animated: true)
            } else {
                call.reject("Could not present SMS composer")
            }
        }
    }

    // Handle SMS composition result
    public func messageComposeViewController(_ controller: MFMessageComposeViewController,
                                           didFinishWith result: MessageComposeResult) {
        DispatchQueue.main.async { [weak self] in
            controller.dismiss(animated: true) {
                guard let self = self, let call = self.call else { return }

                var resultData = JSObject()

                switch result {
                case .sent:
                    resultData["sentStatus"] = "SENT"
                    // iOS doesn't provide delivery confirmation
                    resultData["deliveryStatus"] = "UNKNOWN"
                case .failed:
                    resultData["sentStatus"] = "FAILED"
                    resultData["deliveryStatus"] = "FAILED"
                case .cancelled:
                    resultData["sentStatus"] = "CANCELLED"
                    resultData["deliveryStatus"] = "CANCELLED"
                @unknown default:
                    resultData["sentStatus"] = "UNKNOWN"
                    resultData["deliveryStatus"] = "UNKNOWN"
                }

                call.resolve(resultData)
                self.currentComposeVC = nil
                self.call = nil
            }
        }
    }

    // Check for SIM card availability
    private func hasSimCard() -> Bool {
        let networkInfo = CTTelephonyNetworkInfo()
        guard let carriers = networkInfo.serviceSubscriberCellularProviders else {
            return false
        }

        // Check if any carrier has valid SIM
        return carriers.values.contains { carrier in
            carrier.mobileCountryCode != nil && carrier.mobileNetworkCode != nil
        }
    }
}

Здесь важно понимать:

  • пользователь всегда видит системный UI;

  • доставку узнать нельзя;

Тестирование

  • тестировать только на реальном устройстве;

  • нужна SIM-карта;

Сборка плагина

В самом базовом случае (в нашем) будет достаточно оставить package.json в следующем виде:

{
  "name": "@bs-solutions/bastion-sms-plugin",
  "version": "1.0.1",
  "description": "Basic SMS Android/IOS plugin for Capacitor 6 and higher",
  "main": "dist/plugin.cjs.js",
  "module": "dist/esm/index.js",
  "types": "dist/esm/index.d.ts",
  "unpkg": "dist/plugin.js",
  "type": "module",
  "files": [
    "dist"
  ],
  "author": "sudondie <ilia.skakov@gmail.com>",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Bastion-RND/bastion-sms-plugin.git"
  },
  "keywords": [
    "capacitor",
    "plugin",
    "native"
  ],
  "scripts": {
    "verify": "npm run verify:ios && npm run verify:android",
    "verify:ios": "xcodebuild -scheme BsSolutionsBastionSmsPlugin -destination generic/platform=iOS",
    "verify:android": "cd android && ./gradlew clean build test && cd ..",
    "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
    "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
    "eslint": "eslint . --ext ts",
    "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
    "swiftlint": "node-swiftlint",
    "build": "npm run clean && tsc && rollup -c rollup.config.mjs",
    "clean": "rimraf ./dist",
    "watch": "tsc --watch",
    "prepublishOnly": "npm run build"
  },
  "devDependencies": {
    "@capacitor/android": ">=6.0.0",
    "@capacitor/core": ">=6.0.0",
    "@capacitor/ios": ">=6.0.0",
    "@ionic/eslint-config": "^0.4.0",
    "@ionic/prettier-config": "^4.0.0",
    "@ionic/swiftlint-config": "^2.0.0",
    "eslint": "^8.57.0",
    "prettier": "^3.4.2",
    "prettier-plugin-java": "^2.6.6",
    "rimraf": "^6.0.1",
    "rollup": "^4.30.1",
    "swiftlint": "^2.0.0",
    "typescript": "~4.1.5"
  },
  "peerDependencies": {
    "@capacitor/core": ">=6.2.0"
  },
  "prettier": "@ionic/prettier-config",
  "swiftlint": "@ionic/swiftlint-config",
  "eslintConfig": {
    "extends": "@ionic/eslint-config/recommended"
  },
  "capacitor": {
    "ios": {
      "src": "ios"
    },
    "android": {
      "src": "android"
    }
  }
}

Для сборки dist с плагином выполняем npm run build. После этого можно:

  • Публиковать плагин на npm например при помощи команды npm publish --access public.

  • Создать архив с помощью команды npm pack и передавать его на флешке (шутка)

  • Опубликовать приватный npm репозиторий, но это стоит денег

Что нужно улучшить в плагине

Чтобы плагин выглядел законченно и был готов к нормальному продакшену нужно:

  • добавить корректную обработку нескольких SIM;

  • унифицировать статусы;

  • добавить README с примерами;

  • покрыть web-реализацию явной ошибкой.

Итог

Плагины — это пожалуй главная фича Capacitor.

Официальные плагины закрывают много задач но далеко не все, и тогда на помощь приходят всевозможные плагины сообщества.

Если нет ни официального плагина, ни плагина сообщества тогда приходится писать свою реализацию. И именно в этот момент фронтендер начинает понимать, что мобильная разработка — это не только React, но и:

  • разрешения;

  • ограничения платформ;

  • различия Android и iOS.

SMS-плагин — простой пример, но он отлично показывает, как устроен мост между вебом и нативом.

На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.

Ссылки: