~/blog/capacitor-android-native-plugins
$cat capacitor-android-native-plugins.md_
MOBILE2025-11-05◷ 13 min
Capacitor.js: Bridging React PWA to Android Native
# Capacitor.js: Bridging React PWA to Android Native
When web APIs aren't enough, Capacitor provides the bridge to native Android functionality. This guide covers building custom plugins for camera, GPS, and Firebase Cloud Messaging.
## Understanding the Bridge
Capacitor creates a two-way communication channel between your web code and native Android:
┌─────────────────────┐ ┌─────────────────────┐
│ Web (React) │ │ Native (Kotlin) │
├─────────────────────┤ ├─────────────────────┤
│ │ │ │
│ Capacitor.Plugins │◀───▶│ @CapacitorPlugin │
│ .MyPlugin.method() │ │ fun method() │
│ │ │ │
└─────────────────────┘ └─────────────────────┘
│ │
└───────── JSON ────────────┘
## Custom Camera Plugin
### Native Side (Kotlin)
kotlin@CapacitorPlugin( name = "CustomCamera", permissions = [ Permission( strings = [Manifest.permission.CAMERA], alias = "camera" ) ] ) class CustomCameraPlugin : Plugin() { @PluginMethod fun takePhoto(call: PluginCall) { val quality = call.getInt("quality", 90) if (getPermissionState("camera") != PermissionState.GRANTED) { requestPermissionForAlias("camera", call, "cameraPermissionCallback") return } val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) startActivityForResult(call, intent, "handlePhotoResult") } @ActivityCallback private fun handlePhotoResult(call: PluginCall, result: ActivityResult) { if (result.resultCode == Activity.RESULT_OK) { val bitmap = result.data?.extras?.get("data") as Bitmap val base64 = bitmapToBase64(bitmap) val ret = JSObject() ret.put("base64", base64) ret.put("format", "jpeg") call.resolve(ret) } else { call.reject("Photo capture cancelled") } } private fun bitmapToBase64(bitmap: Bitmap): String { val stream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) val bytes = stream.toByteArray() return Base64.encodeToString(bytes, Base64.NO_WRAP) } }
### Web Side (TypeScript)
typescriptimport { registerPlugin } from '@capacitor/core'; export interface CustomCameraPlugin { takePhoto(options?: { quality?: number }): Promise<{ base64: string; format: string; }>; } const CustomCamera = registerPlugin<CustomCameraPlugin>('CustomCamera'); export default CustomCamera;
### React Usage
jsximport CustomCamera from './plugins/CustomCamera'; function CameraButton() { const [photo, setPhoto] = useState(null); const handleCapture = async () => { try { const result = await CustomCamera.takePhoto({ quality: 85 }); setPhoto(`data:image/jpeg;base64,${result.base64}`); } catch (error) { console.error('Camera error:', error); } }; return ( <div> <button onClick={handleCapture}>Take Photo</button> {photo && <img src={photo} alt="Captured" />} </div> ); }
## GPS Location Plugin
High-accuracy location tracking with battery optimization:
kotlin@CapacitorPlugin(name = "CustomLocation") class CustomLocationPlugin : Plugin() { private var locationClient: FusedLocationProviderClient? = null private var locationCallback: LocationCallback? = null @PluginMethod fun startTracking(call: PluginCall) { val interval = call.getLong("interval", 5000) locationClient = LocationServices.getFusedLocationProviderClient(activity) val request = LocationRequest.create().apply { this.interval = interval fastestInterval = interval / 2 priority = LocationRequest.PRIORITY_HIGH_ACCURACY } locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { result.lastLocation?.let { location -> val data = JSObject() data.put("latitude", location.latitude) data.put("longitude", location.longitude) data.put("accuracy", location.accuracy) data.put("timestamp", location.time) notifyListeners("locationUpdate", data) } } } locationClient?.requestLocationUpdates( request, locationCallback!!, Looper.getMainLooper() ) call.resolve() } @PluginMethod fun stopTracking(call: PluginCall) { locationCallback?.let { locationClient?.removeLocationUpdates(it) } call.resolve() } }
## Firebase Cloud Messaging
Push notifications require both native and web handling:
### Native Registration
kotlin@CapacitorPlugin(name = "CustomFCM") class CustomFCMPlugin : Plugin() { @PluginMethod fun getToken(call: PluginCall) { FirebaseMessaging.getInstance().token .addOnCompleteListener { task -> if (task.isSuccessful) { val ret = JSObject() ret.put("token", task.result) call.resolve(ret) } else { call.reject("Failed to get FCM token") } } } @PluginMethod fun subscribeToTopic(call: PluginCall) { val topic = call.getString("topic") ?: run { call.reject("Topic required") return } FirebaseMessaging.getInstance() .subscribeToTopic(topic) .addOnCompleteListener { task -> if (task.isSuccessful) { call.resolve() } else { call.reject("Subscription failed") } } } }
### Message Service
kotlinclass CustomFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { message.notification?.let { notification -> showNotification( notification.title ?: "", notification.body ?: "" ) } // Forward data payload to web layer message.data.isNotEmpty().let { val intent = Intent("FCM_MESSAGE") intent.putExtra("data", JSONObject(message.data as Map<*, *>).toString()) LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } } override fun onNewToken(token: String) { // Send to your server sendTokenToServer(token) } }
## Plugin Registration
Register plugins in MainActivity.java:
javapublic class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(CustomCameraPlugin.class); registerPlugin(CustomLocationPlugin.class); registerPlugin(CustomFCMPlugin.class); super.onCreate(savedInstanceState); } }
## Testing Strategies
| Component | Test Method |
|---|---|
| Plugin Interface | Jest mocks |
| Native Code | Android Instrumented Tests |
| Integration | Real device testing |
| Permissions | Manual testing |
## Common Pitfalls
- >Permission handling - Always check before accessing protected features
- >Lifecycle management - Clean up resources in
handleOnDestroy - >Thread safety - Native callbacks may not be on main thread
- >Error serialization - Exceptions must be converted to strings
## Conclusion
Capacitor's plugin architecture cleanly separates web and native concerns while providing seamless communication. Custom plugins unlock device capabilities that web APIs can't reach, enabling truly native experiences from your React codebase.