0%

Native Module in Android for React-Native

November 18, 2025

React-Native

1. Expo Modules (Kotlin Only)

1.1. Module Structure

modules/expo-audio-converter/
├── android/                          # Android native code
│   ├── build.gradle                  # Android build config
│   └── src/main/java/expo/modules/audioconverter/
│       └── ExpoAudioConverterModule.kt  # Native implementation
├── ios/                              # iOS native code (if needed)
│   └── ExpoAudioConverterModule.swift
├── src/
│   └── ExpoAudioConverterModule.ts   # TypeScript interface
├── index.ts                          # Public API
└── expo-module.config.json           # Module configuration
1.1.1. build.gradle Template

The following build configuration is a boilerplate for android plugin in react-native:

apply plugin: 'com.android.library'

group = 'expo.modules.audioconverter'
version = '0.6.3'

def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()

// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
// Most of the time, you may like to manage the Android SDK versions yourself.
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
  useDefaultAndroidSdkVersions()
} else {
  buildscript {
    // Simple helper that allows the root project to override versions declared by this library.
    ext.safeExtGet = { prop, fallback ->
      rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
    }
  }
  project.android {
    compileSdkVersion safeExtGet("compileSdkVersion", 34)
    defaultConfig {
      minSdkVersion safeExtGet("minSdkVersion", 21)
      targetSdkVersion safeExtGet("targetSdkVersion", 34)
    }
  }
}

android {
  namespace "expo.modules.audioconverter"
  defaultConfig {
    versionCode 1
    versionName "0.6.3"
  }
  lintOptions {
    abortOnError false
  }
}
1.1.2. Define Native Module in Kotlin
1package expo.modules.audioconverter
2
3import android.media.MediaCodec
4import android.media.MediaExtractor
5import android.media.MediaFormat
6import expo.modules.kotlin.modules.Module
7import expo.modules.kotlin.modules.ModuleDefinition
8import java.io.File
9import java.io.FileOutputStream
10import java.io.RandomAccessFile
11
12class ExpoAudioConverterModule : Module() {
13    override fun definition() = ModuleDefinition {
14        Name("ExpoAudioConverter")
15
16        AsyncFunction("convertToWav") { inputPath: String, outputPath: String ->
17            try {
18                // Remove file:// prefix if present
19                val cleanInputPath = inputPath.removePrefix("file://")
20                val cleanOutputPath = outputPath.removePrefix("file://")
21                
22                val inputFile = File(cleanInputPath)
23                val outputFile = File(cleanOutputPath)
24                
25                if (!inputFile.exists()) {
26                    throw Error("Input file does not exist: $cleanInputPath")
27                }
28                
29                val converter = AudioConverter()
30                converter.convertToWav(inputFile, outputFile)
31                
32                if (!outputFile.exists()) {
33                    throw Error("Output file was not created: $cleanOutputPath")
34                }
35                
36                mapOf(
37                    "outputPath" to outputFile.absolutePath
38                )
39            } catch (e: Exception) {
40                throw Error("Conversion failed: ${e.message}")
41            }
42        }
43    } 
44}
  • Note that in line 14 we register the name of the module (the class), in typescript (only within this plugin project) we will call

    const theInstance = requireNativeModule<ExpoAudioConverterModule>(
        "ExpoAudioConverter"
    )

    to instantiate the registered instance.

  • Also in expo-module.config.json (go 〈1.1. Module Structure〉 to see where it is located) we record the module that we want to export:

    {
      "platforms": ["android"],
      "android": {
        "modules": ["expo.modules.audioconverter.ExpoAudioConverterModule"]
      }
    }

    On starting the application, Expo will scan for the ExpoAudioConverterModule and bridge our typescript code to this kotlin instance.

    Remark. Note that we provide the full classpath in the json, not the filename.

  • Here Module, ModuleDefinition are all provided by the dependencies injected by useCoreDependencies().

  • Within the scope (trailing closure) of ModuleDefinition, AsyncFunction is one of the method provided by the class ModuleDefinition via function literal (for detail, refer to my article Function Literals with Receiver).

  • For synchrouous operations we can implement Function in place of AsyncFunction.

1.1.3. Create TypeScript Interface and Wrapper for the Module
import { NativeModule, requireNativeModule } from "expo"
import { Platform } from "react-native"

export interface ConvertToWavResult {
    outputPath: string
}

declare class ExpoAudioConverterModule extends NativeModule {
    convertToWav(inputPath: string, outputPath: string): Promise<ConvertToWavResult>
}

// This call loads the native module object from the JSI.
let AudioConverterModule: ExpoAudioConverterModule | null = null
try {
    // Only attempt to load on Android
    if (Platform.OS === "android") {
        AudioConverterModule = requireNativeModule<ExpoAudioConverterModule>("ExpoAudioConverter")
    }
} catch (e) {
    // Module failed to load
    console.warn("ExpoAudioConverter native module not available")
}
export async function convertToWav(inputPath: string, outputPath: string): Promise<ConvertToWavResult> {
    if (!AudioConverterModule) {
        throw new Error("ExpoAudioConverter native module not available")
    }
    return await AudioConverterModule.convertToWav(inputPath, outputPath)
}

So basically we don't export the class ExpoAudioConverter, we simply instantiate it in the typescript file and export the execution convertToWav for use.

1.2. Key Expo Modules Concepts

1.2.1. AsyncFunction
  • Purpose: Define async native functions callable from JavaScript
  • Syntax: AsyncFunction("name") { param1: Type, param2: Type -> ... }
  • Returns: Automatically wrapped in a Promise
  • Errors: Thrown errors become rejected Promises
// Native
AsyncFunction("myFunction") { input: String ->
    if (input.isEmpty()) throw Error("Input is empty")
    return "Success"
}

// JavaScript
try {
    const result = await myFunction("")  // Throws error
} catch (e) {
    console.error(e.message)  // "Input is empty"
}
1.2.2. Type Conversion

Expo automatically converts between JavaScript and native types:

JavaScriptKotlinSwift
stringStringString
numberInt, DoubleInt, Double
booleanBooleanBool
objectMap<String, Any?>[String: Any?]
arrayList<Any?>[Any?]
Promisesuspend funasync
1.2.3. Module Configuration
// expo-module.config.json
{
    "platforms": ["android", "ios"],
    "android": {
        "modules": ["expo.modules.audioconverter.ExpoAudioConverterModule"]
    },
    "ios": {
        "modules": ["ExpoAudioConverterModule"]
    }
}

2.1. Implementation

class AudioConverter {
    fun convertToWav(inputFile: File, outputFile: File) {
        val extractor = MediaExtractor()
        var format: MediaFormat? = null
        
        try {
            extractor.setDataSource(inputFile.absolutePath)
            
            // Find the audio track
            for (i in 0 until extractor.trackCount) {
                format = extractor.getTrackFormat(i)
                if (format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) {
                    extractor.selectTrack(i)
                    break
                }
            }

            if (format == null) {
                throw IllegalArgumentException("No audio track found")
            }

            // Get audio properties
            val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
            println("sampleRate: $sampleRate")
            val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
            println("channelCount: $channelCount")
            // Create decoder
            val mime = format.getString(MediaFormat.KEY_MIME)
            val decoder = MediaCodec.createDecoderByType(mime!!)
            decoder.configure(format, null, null, 0)
            decoder.start()

            // Prepare output stream and write WAV header
            val outputStream = FileOutputStream(outputFile)
            writeWavHeader(outputStream, 0, sampleRate, channelCount)
            
            // Start decoding
            val info = MediaCodec.BufferInfo()
            var totalBytes = 0
            
            while (true) {
                val inputBufferId = decoder.dequeueInputBuffer(10000)
                if (inputBufferId >= 0) {
                    val inputBuffer = decoder.getInputBuffer(inputBufferId)!!
                    val sampleSize = extractor.readSampleData(inputBuffer, 0)
                    
                    if (sampleSize < 0) {
                        decoder.queueInputBuffer(inputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                    } else {
                        decoder.queueInputBuffer(inputBufferId, 0, sampleSize, extractor.sampleTime, 0)
                        extractor.advance()
                    }
                }

                val outputBufferId = decoder.dequeueOutputBuffer(info, 10000)
                if (outputBufferId >= 0) {
                    val outputBuffer = decoder.getOutputBuffer(outputBufferId)!!
                    val chunk = ByteArray(info.size)
                    outputBuffer.get(chunk)
                    outputBuffer.clear()
                    
                    if (chunk.isNotEmpty()) {
                        outputStream.write(chunk)
                        totalBytes += chunk.size
                    }
                    
                    decoder.releaseOutputBuffer(outputBufferId, false)
                    
                    if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
                        break
                    }
                }
            }

            // Clean up
            outputStream.close()
            decoder.stop()
            decoder.release()
            extractor.release()

            // Update WAV header with final size
            updateWavHeader(outputFile, totalBytes)

        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    private fun writeWavHeader(
        outputStream: FileOutputStream,
        totalAudioLen: Int,
        sampleRate: Int,
        channels: Int
    ) {
        val totalDataLen = totalAudioLen + 36
        val byteRate = sampleRate * channels * 2 // 16 bits per sample

        val header = ByteArray(44).apply {
            // RIFF header
            this[0] = 'R'.code.toByte()
            this[1] = 'I'.code.toByte()
            this[2] = 'F'.code.toByte()
            this[3] = 'F'.code.toByte()
            // Total file size - 8
            this[4] = (totalDataLen and 0xff).toByte()
            this[5] = (totalDataLen shr 8 and 0xff).toByte()
            this[6] = (totalDataLen shr 16 and 0xff).toByte()
            this[7] = (totalDataLen shr 24 and 0xff).toByte()
            // WAVE
            this[8] = 'W'.code.toByte()
            this[9] = 'A'.code.toByte()
            this[10] = 'V'.code.toByte()
            this[11] = 'E'.code.toByte()
            // fmt chunk
            this[12] = 'f'.code.toByte()
            this[13] = 'm'.code.toByte()
            this[14] = 't'.code.toByte()
            this[15] = ' '.code.toByte()
            // fmt chunk size
            this[16] = 16
            this[17] = 0
            this[18] = 0
            this[19] = 0
            // Audio format (PCM)
            this[20] = 1
            this[21] = 0
            // Number of channels
            this[22] = channels.toByte()
            this[23] = 0
            // Sample rate
            this[24] = (sampleRate and 0xff).toByte()
            this[25] = (sampleRate shr 8 and 0xff).toByte()
            this[26] = (sampleRate shr 16 and 0xff).toByte()
            this[27] = (sampleRate shr 24 and 0xff).toByte()
            // Byte rate
            this[28] = (byteRate and 0xff).toByte()
            this[29] = (byteRate shr 8 and 0xff).toByte()
            this[30] = (byteRate shr 16 and 0xff).toByte()
            this[31] = (byteRate shr 24 and 0xff).toByte()
            // Block align
            this[32] = (channels * 2).toByte()
            this[33] = 0
            // Bits per sample
            this[34] = 16
            this[35] = 0
            // data chunk
            this[36] = 'd'.code.toByte()
            this[37] = 'a'.code.toByte()
            this[38] = 't'.code.toByte()
            this[39] = 'a'.code.toByte()
            // Data size
            this[40] = (totalAudioLen and 0xff).toByte()
            this[41] = (totalAudioLen shr 8 and 0xff).toByte()
            this[42] = (totalAudioLen shr 16 and 0xff).toByte()
            this[43] = (totalAudioLen shr 24 and 0xff).toByte()
        }
        
        outputStream.write(header, 0, 44)
    }

    private fun updateWavHeader(file: File, totalAudioLen: Int) {
        RandomAccessFile(file, "rw").use { raf ->
            // Update file size
            raf.seek(4)
            val totalDataLen = totalAudioLen + 36
            raf.write((totalDataLen and 0xff).toByte().toInt())
            raf.write((totalDataLen shr 8 and 0xff).toByte().toInt())
            raf.write((totalDataLen shr 16 and 0xff).toByte().toInt())
            raf.write((totalDataLen shr 24 and 0xff).toByte().toInt())
            
            // Update data size
            raf.seek(40)
            raf.write((totalAudioLen and 0xff).toByte().toInt())
            raf.write((totalAudioLen shr 8 and 0xff).toByte().toInt())
            raf.write((totalAudioLen shr 16 and 0xff).toByte().toInt())
            raf.write((totalAudioLen shr 24 and 0xff).toByte().toInt())
        }
    }
}

2.2. Code Breakdown and Learning Resources

2.2.1. STEP 1: Extract audio metadata

Reference: https://developer.android.com/reference/android/media/MediaExtractor

val extractor = MediaExtractor()
extractor.setDataSource(inputFile.absolutePath)
val format = extractor.getTrackFormat(0)  // Get audio format info
2.2.2. STEP 2: Create decoder

Reference: https://developer.android.com/reference/android/media/MediaCodec

val decoder = MediaCodec.createDecoderByType(mime)  // AAC, MP3, etc.
decoder.configure(format, null, null, 0)
decoder.start()
2.2.3. STEP 3: Decode loop (Producer-Consumer pattern)
while (true) {
    // Producer: Feed compressed data
    val inputBufferId = decoder.dequeueInputBuffer(10000)
    extractor.readSampleData(inputBuffer, 0)
    decoder.queueInputBuffer(...)
    
    // Consumer: Get decoded PCM
    val outputBufferId = decoder.dequeueOutputBuffer(info, 10000)
    outputStream.write(pcmData)
}
2.2.4. STEP 4: WAV header

Reference: http://soundfile.sapp.org/doc/WaveFormat/

writeWavHeader(outputStream, totalBytes, sampleRate, channels)