Capacitor: от веба к мобильным приложениям. Часть 2. Как написать свой плагин (Android + iOS)
- четверг, 19 февраля 2026 г. в 00:00:13
В прошлых частях мы разобрали:
зачем выбирать Capacitor;
как мигрировать веб-приложение;
какие проблемы могут возникнуть при переносе;
Теперь переходим к самому важному месту во всей архитектуре Capacitor — к плагинам.
Именно плагины делают из WebView полноценное мобильное приложение. С ними у Вас появляется доступ к камере, файловой системе, push-уведомлениям, Bluetooth и т.д
В этой статье разберем:
как устанавливаются официальные плагины;
как работать с community-плагинами;
как мигрировать с Cordova;
и главное — как написать собственный плагин с нуля на реальном примере отправки SMS.
Официальные плагины поддерживаются командой 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

Свой плагин нужен, если:
нет готового решения;
требуется специфичный нативный SDK (например Huawei ML kit);
нужно инкапсулировать чувствительную логику;
платформа ведет себя по-разному (как Android и iOS с SMS).
Отправка SMS — идеальный пример.
В браузере это невозможно.
В WebView — тоже невозможно.
Значит, нужен мост к нативному API.
Создание плагина покажем на примере написания собственного невероятно скромного плагина по отправке 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
В этом файле нам нужно описать все возможные методы которые есть в плагине и отдать наружу интерфейс нашего плагина:
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:
import { registerPlugin } from '@capacitor/core'; import type { BastionSMSPlugin } from './definitions'; export const BastionSMS = registerPlugin<BastionSMSPlugin>('BastionSMS');
Ее мы писать не будем. Но если бы написали то регистрировали бы как-то так:
const Network = registerPlugin<NetworkPlugin>('Network', { web: () => import('./web').then((m) => new m.NetworkWeb()), });
Перед тем как приступить к нативным реализациям плагина хочется объявить небольшой дисклеймер:
Автор статьи является frontend разработчиком и не обладает в полной мере знаниями нативных платформ. Поэтому код реализаций предлагается принять таким какой он есть. Попросите SourceCraft обяъснить код. Суть статьи не в самом написании нативного кода, а в том чтобы показать процесс создания плагинов.
Android позволяет отправлять SMS без UI через SmsManager.
Нужно добавить разрешение:
<uses-permission android:name="android.permission.SEND_SMS" />
Также потребуется runtime-разрешение. Например использовать метод getPermissionsAuthorizationStatus из плагина @awesome-cordova-plugins/diagnostic
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;
добавить таймаут на доставку потому что ее может не произойти
На iOS нельзя отправить SMS “тихо”.
Можно только открыть системное окно MFMessageComposeViewController.
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-плагин — простой пример, но он отлично показывает, как устроен мост между вебом и нативом.
На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.
Ссылки: