├── .watchmanconfig ├── example ├── .watchmanconfig ├── jest.config.js ├── .bundle │ └── config ├── .eslintrc.js ├── app.json ├── .prettierrc.js ├── android │ ├── app │ │ ├── debug.keystore │ │ ├── src │ │ │ └── main │ │ │ │ ├── res │ │ │ │ ├── values │ │ │ │ │ ├── strings.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ └── drawable │ │ │ │ │ └── rn_edit_text_material.xml │ │ │ │ ├── java │ │ │ │ └── com │ │ │ │ │ └── inappbrowsernitroexample │ │ │ │ │ ├── MainApplication.kt │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── AndroidManifest.xml │ │ ├── proguard-rules.pro │ │ └── build.gradle │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ ├── settings.gradle │ ├── build.gradle │ ├── gradle.properties │ ├── gradlew.bat │ └── gradlew ├── ios │ ├── InAppBrowserNitroExample │ │ ├── Images.xcassets │ │ │ ├── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── PrivacyInfo.xcprivacy │ │ ├── AppDelegate.swift │ │ ├── Info.plist │ │ └── LaunchScreen.storyboard │ ├── InappbrowserNitroExample.xcworkspace │ │ └── contents.xcworkspacedata │ ├── .xcode.env │ ├── Podfile │ └── InAppBrowserNitroExample.xcodeproj │ │ ├── xcshareddata │ │ └── xcschemes │ │ │ └── InAppBrowserNitroExample.xcscheme │ │ └── project.pbxproj ├── index.js ├── tsconfig.json ├── react-native.config.js ├── metro.config.js ├── babel.config.js ├── Gemfile ├── package.json ├── .gitignore ├── Gemfile.lock ├── README.md └── App.tsx ├── ios ├── Bridge.h ├── String+Nitro.swift ├── UIApplication+TopMost.swift ├── HybridInappbrowserNitro.swift ├── UIColor+DynamicColor.swift ├── AuthSessionManager.swift └── SafariPresenter.swift ├── app.gif ├── .yarnrc.yml ├── babel.config.js ├── android ├── src │ └── main │ │ ├── cpp │ │ └── cpp-adapter.cpp │ │ ├── java │ │ └── com │ │ │ └── inappbrowsernitro │ │ │ ├── InappbrowserNitroPackage.kt │ │ │ ├── browser │ │ │ ├── CustomTabsPackageHelper.kt │ │ │ ├── BrowserFallback.kt │ │ │ ├── DynamicColorResolver.kt │ │ │ └── CustomTabsIntentFactory.kt │ │ │ └── HybridInappbrowserNitro.kt │ │ └── AndroidManifest.xml ├── gradle.properties ├── CMakeLists.txt ├── fix-prefab.gradle └── build.gradle ├── nitro.json ├── post-script.js ├── src ├── utils │ ├── url.ts │ └── options.ts ├── index.ts ├── core │ └── InAppBrowser.ts ├── hooks │ └── useInAppBrowser.ts └── specs │ └── inappbrowser-nitro.nitro.ts ├── scripts └── remove-source-maps.cjs ├── InAppBrowserNitro.podspec ├── tsconfig.json ├── .gitignore ├── LICENSE ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── android-build.yml │ └── ios-build.yml ├── release.config.cjs ├── package.json └── README.md /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /example/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ios/Bridge.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | -------------------------------------------------------------------------------- /example/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | }; 4 | -------------------------------------------------------------------------------- /app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/app.gif -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.11.0.cjs 4 | -------------------------------------------------------------------------------- /example/.bundle/config: -------------------------------------------------------------------------------- 1 | BUNDLE_PATH: "vendor/bundle" 2 | BUNDLE_FORCE_RUBY_PLATFORM: 1 3 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | } 4 | -------------------------------------------------------------------------------- /example/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native', 4 | }; 5 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "InappbrowserNitroExample", 3 | "displayName": "InappbrowserNitroExample" 4 | } 5 | -------------------------------------------------------------------------------- /example/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | }; 6 | -------------------------------------------------------------------------------- /example/android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/debug.keystore -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | InappbrowserNitroExample 3 | 4 | -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mCodex/react-native-inappbrowser-nitro/HEAD/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/cpp/cpp-adapter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include "InappbrowserNitroOnLoad.hpp" 3 | 4 | JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { 5 | return margelo::nitro::inappbrowsernitro::initialize(vm); 6 | } 7 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | InappbrowserNitro_kotlinVersion=2.1.20 2 | InappbrowserNitro_minSdkVersion=23 3 | InappbrowserNitro_targetSdkVersion=35 4 | InappbrowserNitro_compileSdkVersion=34 5 | InappbrowserNitro_ndkVersion=27.1.12297006 6 | -------------------------------------------------------------------------------- /ios/String+Nitro.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | var nitroTrimmedNonEmpty: String? { 5 | let result = trimmingCharacters(in: .whitespacesAndNewlines) 6 | return result.isEmpty ? nil : result 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import { AppRegistry } from 'react-native'; 6 | import App from './App'; 7 | import { name as appName } from './app.json'; 8 | 9 | AppRegistry.registerComponent(appName, () => App); 10 | -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@react-native/typescript-config", 3 | "include": ["**/*.ts", "**/*.tsx"], 4 | "exclude": ["**/node_modules", "**/Pods"], 5 | "compilerOptions": { 6 | "strict": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "react-native-inappbrowser-nitro": ["../src"] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /example/ios/InappbrowserNitroExample.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /example/android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { includeBuild("../../node_modules/@react-native/gradle-plugin") } 2 | plugins { id("com.facebook.react.settings") } 3 | extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } 4 | rootProject.name = 'InappbrowserNitroExample' 5 | include ':app' 6 | includeBuild('../../node_modules/@react-native/gradle-plugin') 7 | -------------------------------------------------------------------------------- /example/react-native.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const pkg = require('../package.json') 3 | 4 | /** 5 | * @type {import('@react-native-community/cli-types').Config} 6 | */ 7 | module.exports = { 8 | project: { 9 | ios: { 10 | automaticPodsInstallation: true, 11 | }, 12 | }, 13 | dependencies: { 14 | [pkg.name]: { 15 | root: path.join(__dirname, '..'), 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); 2 | const path = require('path'); 3 | const root = path.resolve(__dirname, '..'); 4 | 5 | /** 6 | * Metro configuration 7 | * https://facebook.github.io/metro/docs/configuration 8 | * 9 | * @type {import('metro-config').MetroConfig} 10 | */ 11 | const config = { 12 | watchFolders: [root], 13 | }; 14 | 15 | module.exports = mergeConfig(getDefaultConfig(__dirname), config); -------------------------------------------------------------------------------- /example/android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pak = require('../package.json'); 3 | 4 | module.exports = api => { 5 | api.cache(true); 6 | return { 7 | presets: ['module:@react-native/babel-preset'], 8 | plugins: [ 9 | [ 10 | 'module-resolver', 11 | { 12 | extensions: ['.js', '.ts', '.json', '.jsx', '.tsx'], 13 | alias: { 14 | [pak.name]: path.join(__dirname, '../', pak.source), 15 | }, 16 | }, 17 | ], 18 | ], 19 | }; 20 | }; -------------------------------------------------------------------------------- /example/ios/.xcode.env: -------------------------------------------------------------------------------- 1 | # This `.xcode.env` file is versioned and is used to source the environment 2 | # used when running script phases inside Xcode. 3 | # To customize your local environment, you can create an `.xcode.env.local` 4 | # file that is not versioned. 5 | 6 | # NODE_BINARY variable contains the PATH to the node executable. 7 | # 8 | # Customize the NODE_BINARY variable here. 9 | # For example, to use nvm with brew, add the following line 10 | # . "$(brew --prefix nvm)/nvm.sh" --no-use 11 | export NODE_BINARY=$(command -v node) 12 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version 4 | ruby ">= 2.6.10" 5 | 6 | # Exclude problematic versions of cocoapods and activesupport that causes build failures. 7 | gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' 8 | gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' 9 | gem 'xcodeproj', '< 1.26.0' 10 | gem 'concurrent-ruby', '< 1.3.4' 11 | 12 | # Ruby 3.4.0 has removed some libraries from the standard library. 13 | gem 'bigdecimal' 14 | gem 'logger' 15 | gem 'benchmark' 16 | gem 'mutex_m' 17 | -------------------------------------------------------------------------------- /nitro.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://nitro.margelo.com/nitro.schema.json", 3 | "cxxNamespace": [ 4 | "inappbrowsernitro" 5 | ], 6 | "ios": { 7 | "iosModuleName": "InappbrowserNitro" 8 | }, 9 | "android": { 10 | "androidNamespace": [ 11 | "inappbrowsernitro" 12 | ], 13 | "androidCxxLibName": "InappbrowserNitro" 14 | }, 15 | "autolinking": { 16 | "InappbrowserNitro": { 17 | "swift": "HybridInappbrowserNitro", 18 | "kotlin": "HybridInappbrowserNitro" 19 | } 20 | }, 21 | "ignorePaths": [ 22 | "**/node_modules" 23 | ] 24 | } -------------------------------------------------------------------------------- /example/android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | buildToolsVersion = "36.0.0" 4 | minSdkVersion = 24 5 | compileSdkVersion = 36 6 | targetSdkVersion = 36 7 | ndkVersion = "27.1.12297006" 8 | kotlinVersion = "2.1.20" 9 | } 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | dependencies { 15 | classpath("com.android.tools.build:gradle") 16 | classpath("com.facebook.react:react-native-gradle-plugin") 17 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") 18 | } 19 | } 20 | 21 | apply plugin: "com.facebook.react.rootproject" 22 | -------------------------------------------------------------------------------- /ios/UIApplication+TopMost.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIApplication { 4 | var nitroTopMostViewController: UIViewController? { 5 | guard Thread.isMainThread else { 6 | return DispatchQueue.main.sync { 7 | self.nitroTopMostViewController 8 | } 9 | } 10 | 11 | let windowScene = connectedScenes 12 | .compactMap { $0 as? UIWindowScene } 13 | .flatMap { $0.windows } 14 | .first { $0.isKeyWindow } 15 | 16 | guard let root = windowScene?.rootViewController else { 17 | return nil 18 | } 19 | 20 | var candidate: UIViewController? = root 21 | while let presented = candidate?.presentedViewController { 22 | candidate = presented 23 | } 24 | 25 | return candidate 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /android/src/main/java/com/inappbrowsernitro/InappbrowserNitroPackage.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitro; 2 | 3 | import com.facebook.react.bridge.NativeModule; 4 | import com.facebook.react.bridge.ReactApplicationContext; 5 | import com.facebook.react.module.model.ReactModuleInfoProvider; 6 | import com.facebook.react.TurboReactPackage; 7 | import com.margelo.nitro.inappbrowsernitro.InappbrowserNitroOnLoad; 8 | 9 | 10 | public class InappbrowserNitroPackage : TurboReactPackage() { 11 | override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null 12 | 13 | override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { emptyMap() } 14 | 15 | companion object { 16 | init { 17 | InappbrowserNitroOnLoad.initializeNative(); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /post-script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file This script is auto-generated by create-nitro-module and should not be edited. 3 | * 4 | * @description This script applies a workaround for Android by modifying the 'OnLoad.cpp' file. 5 | * It reads the file content and removes the 'margelo/nitro/' string from it. This enables support for custom package names. 6 | * 7 | * @module create-nitro-module 8 | */ 9 | const path = require('node:path') 10 | const { writeFile, readFile } = require('node:fs/promises') 11 | 12 | const androidWorkaround = async () => { 13 | const androidOnLoadFile = path.join( 14 | process.cwd(), 15 | 'nitrogen/generated/android', 16 | 'InappbrowserNitroOnLoad.cpp' 17 | ) 18 | 19 | const str = await readFile(androidOnLoadFile, { encoding: 'utf8' }) 20 | 21 | await writeFile(androidOnLoadFile, str.replace(/margelo\/nitro\//g, '')) 22 | } 23 | 24 | androidWorkaround() -------------------------------------------------------------------------------- /android/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | project(InappbrowserNitro) 2 | cmake_minimum_required(VERSION 3.9.0) 3 | 4 | set (PACKAGE_NAME InappbrowserNitro) 5 | set (CMAKE_VERBOSE_MAKEFILE ON) 6 | set (CMAKE_CXX_STANDARD 20) 7 | 8 | # Enable Raw Props parsing in react-native (for Nitro Views) 9 | add_compile_options(-DRN_SERIALIZABLE_STATE=1) 10 | 11 | # Define C++ library and add all sources 12 | add_library(${PACKAGE_NAME} SHARED 13 | src/main/cpp/cpp-adapter.cpp 14 | ) 15 | 16 | # Add Nitrogen specs :) 17 | include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/InappbrowserNitro+autolinking.cmake) 18 | 19 | # Set up local includes 20 | include_directories( 21 | "src/main/cpp" 22 | "../cpp" 23 | ) 24 | 25 | find_library(LOG_LIB log) 26 | 27 | # Link all libraries together 28 | target_link_libraries( 29 | ${PACKAGE_NAME} 30 | ${LOG_LIB} 31 | android # <-- Android core 32 | ) 33 | -------------------------------------------------------------------------------- /android/src/main/java/com/inappbrowsernitro/browser/CustomTabsPackageHelper.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitro.browser 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import androidx.browser.customtabs.CustomTabsClient 6 | 7 | internal object CustomTabsPackageHelper { 8 | fun resolvePackage(context: Context, preferred: String?): String? { 9 | if (!preferred.isNullOrBlank()) { 10 | return preferred 11 | } 12 | 13 | val chromePackage = "com.android.chrome" 14 | if (isPackageInstalled(context, chromePackage)) { 15 | return chromePackage 16 | } 17 | 18 | return CustomTabsClient.getPackageName(context, null) 19 | } 20 | 21 | private fun isPackageInstalled(context: Context, packageName: String): Boolean { 22 | return try { 23 | context.packageManager.getPackageInfo(packageName, 0) 24 | true 25 | } catch (e: PackageManager.NameNotFoundException) { 26 | false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/com/inappbrowsernitroexample/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitroexample 2 | 3 | import android.app.Application 4 | import com.facebook.react.PackageList 5 | import com.facebook.react.ReactApplication 6 | import com.facebook.react.ReactHost 7 | import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative 8 | import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost 9 | 10 | class MainApplication : Application(), ReactApplication { 11 | 12 | override val reactHost: ReactHost by lazy { 13 | getDefaultReactHost( 14 | context = applicationContext, 15 | packageList = 16 | PackageList(this).packages.apply { 17 | // Packages that cannot be autolinked yet can be added manually here, for example: 18 | // add(MyReactNativePackage()) 19 | }, 20 | ) 21 | } 22 | 23 | override fun onCreate() { 24 | super.onCreate() 25 | loadReactNative(this) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validate and sanitize a URL string before passing it to the native layer. 3 | * Throws if the URL is empty, missing a scheme, or uses an unsafe scheme. 4 | */ 5 | export const normalizeUrl = (candidate: string): string => { 6 | const trimmed = candidate?.trim() 7 | 8 | if (!trimmed) { 9 | throw new Error('URL must be a non-empty string.') 10 | } 11 | 12 | const schemeMatch = trimmed.match(/^([a-z][a-z0-9+.-]*):/i) 13 | if (!schemeMatch) { 14 | throw new Error('URL must include a valid URI scheme (e.g. https://).') 15 | } 16 | 17 | const scheme = schemeMatch[1]?.toLowerCase() 18 | 19 | if (!scheme) { 20 | throw new Error('URL scheme could not be determined.') 21 | } 22 | 23 | const deniedSchemes = new Set(['javascript', 'data', 'vbscript']) 24 | 25 | if (deniedSchemes.has(scheme)) { 26 | throw new Error(`The URI scheme "${scheme}" is not allowed for security reasons.`) 27 | } 28 | 29 | return trimmed 30 | } 31 | -------------------------------------------------------------------------------- /scripts/remove-source-maps.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const { promises: fs } = require('fs') 5 | 6 | async function removeMaps(directory) { 7 | const entries = await fs.readdir(directory, { withFileTypes: true }) 8 | 9 | await Promise.all( 10 | entries.map(async (entry) => { 11 | const entryPath = path.join(directory, entry.name) 12 | 13 | if (entry.isDirectory()) { 14 | await removeMaps(entryPath) 15 | return 16 | } 17 | 18 | if (entry.isFile() && entry.name.endsWith('.map')) { 19 | await fs.unlink(entryPath) 20 | } 21 | }) 22 | ) 23 | } 24 | 25 | async function main() { 26 | const libPath = path.join(__dirname, '..', 'lib') 27 | 28 | try { 29 | await fs.access(libPath) 30 | } catch { 31 | return 32 | } 33 | 34 | await removeMaps(libPath) 35 | } 36 | 37 | main().catch((error) => { 38 | console.error('Failed to remove source maps from lib directory:', error) 39 | process.exitCode = 1 40 | }) 41 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/com/inappbrowsernitroexample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitroexample 2 | 3 | import com.facebook.react.ReactActivity 4 | import com.facebook.react.ReactActivityDelegate 5 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled 6 | import com.facebook.react.defaults.DefaultReactActivityDelegate 7 | 8 | class MainActivity : ReactActivity() { 9 | 10 | /** 11 | * Returns the name of the main component registered from JavaScript. This is used to schedule 12 | * rendering of the component. 13 | */ 14 | override fun getMainComponentName(): String = "InappbrowserNitroExample" 15 | 16 | /** 17 | * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] 18 | * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] 19 | */ 20 | override fun createReactActivityDelegate(): ReactActivityDelegate = 21 | DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) 22 | } 23 | -------------------------------------------------------------------------------- /InAppBrowserNitro.podspec: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | package = JSON.parse(File.read(File.join(__dir__, "package.json"))) 4 | 5 | Pod::Spec.new do |s| 6 | s.name = "InappbrowserNitro" 7 | s.version = package["version"] 8 | s.summary = package["description"] 9 | s.homepage = package["homepage"] 10 | s.license = package["license"] 11 | s.authors = package["author"] 12 | 13 | s.platforms = { :ios => min_ios_version_supported, :visionos => 1.0 } 14 | s.source = { :git => "https://github.com/mCodex/react-native-inappbrowser-nitro.git", :tag => "#{s.version}" } 15 | 16 | s.source_files = [ 17 | # Implementation (Swift) 18 | "ios/**/*.{swift}", 19 | # Autolinking/Registration (Objective-C++) 20 | "ios/**/*.{m,mm}", 21 | # Implementation (C++ objects) 22 | "cpp/**/*.{hpp,cpp}", 23 | ] 24 | 25 | load 'nitrogen/generated/ios/InappbrowserNitro+autolinking.rb' 26 | add_nitrogen_files(s) 27 | 28 | s.dependency 'React-jsi' 29 | s.dependency 'React-callinvoker' 30 | install_modules_dependencies(s) 31 | end 32 | -------------------------------------------------------------------------------- /example/ios/Podfile: -------------------------------------------------------------------------------- 1 | # Resolve react_native_pods.rb with node to allow for hoisting 2 | require Pod::Executable.execute_command('node', ['-p', 3 | 'require.resolve( 4 | "react-native/scripts/react_native_pods.rb", 5 | {paths: [process.argv[1]]}, 6 | )', __dir__]).strip 7 | 8 | platform :ios, min_ios_version_supported 9 | prepare_react_native_project! 10 | 11 | linkage = ENV['USE_FRAMEWORKS'] 12 | if linkage != nil 13 | Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green 14 | use_frameworks! :linkage => linkage.to_sym 15 | end 16 | 17 | target 'InappbrowserNitroExample' do 18 | config = use_native_modules! 19 | 20 | use_react_native!( 21 | :path => config[:reactNativePath], 22 | # An absolute path to your application root. 23 | :app_path => "#{Pod::Config.instance.installation_root}/.." 24 | ) 25 | 26 | post_install do |installer| 27 | react_native_post_install( 28 | installer, 29 | config[:reactNativePath], 30 | :mac_catalyst_enabled => false, 31 | # :ccache_enabled => true 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "allowUnreachableCode": false, 5 | "allowUnusedLabels": false, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react", 9 | "lib": ["esnext"], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noEmit": false, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitReturns": true, 15 | "noImplicitUseStrict": false, 16 | "noStrictGenericChecks": false, 17 | "noUncheckedIndexedAccess": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext", 24 | "verbatimModuleSyntax": true 25 | }, 26 | "exclude": [ 27 | "**/node_modules", 28 | "**/lib", 29 | "**/.eslintrc.js", 30 | "**/.prettierrc.js", 31 | "**/jest.config.js", 32 | "**/babel.config.js", 33 | "**/metro.config.js", 34 | "**/tsconfig.json" 35 | ], 36 | "include": ["src/**/*", "nitrogen/**/*.json"] 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | android.iml 42 | 43 | # Cocoapods 44 | # 45 | example/ios/Pods 46 | 47 | # Ruby 48 | example/vendor/ 49 | 50 | # node.js 51 | # 52 | node_modules/ 53 | npm-debug.log 54 | yarn-debug.log 55 | yarn-error.log 56 | 57 | # BUCK 58 | buck-out/ 59 | \.buckd/ 60 | android/app/libs 61 | android/keystores/debug.keystore 62 | 63 | # Yarn 64 | .yarn/* 65 | !.yarn/patches 66 | !.yarn/plugins 67 | !.yarn/releases 68 | !.yarn/sdks 69 | !.yarn/versions 70 | .kotlin 71 | 72 | # Expo 73 | .expo/ 74 | 75 | # generated by bob 76 | lib/ 77 | tsconfig.tsbuildinfo 78 | 79 | nitrogen/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mateus Andrade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "scale" : "1x", 46 | "size" : "1024x1024" 47 | } 48 | ], 49 | "info" : { 50 | "author" : "xcode", 51 | "version" : 1 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryFileTimestamp 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | C617.1 13 | 14 | 15 | 16 | NSPrivacyAccessedAPIType 17 | NSPrivacyAccessedAPICategoryUserDefaults 18 | NSPrivacyAccessedAPITypeReasons 19 | 20 | CA92.1 21 | 22 | 23 | 24 | NSPrivacyAccessedAPIType 25 | NSPrivacyAccessedAPICategorySystemBootTime 26 | NSPrivacyAccessedAPITypeReasons 27 | 28 | 35F9.1 29 | 30 | 31 | 32 | NSPrivacyCollectedDataTypes 33 | 34 | NSPrivacyTracking 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { InAppBrowser } from './core/InAppBrowser' 2 | import { useInAppBrowser } from './hooks/useInAppBrowser' 3 | 4 | import type { 5 | InAppBrowserAuthResult, 6 | InAppBrowserOptions, 7 | InAppBrowserResult, 8 | InAppBrowserAndroidOptions, 9 | InAppBrowserIOSOptions, 10 | BrowserAnimations, 11 | DynamicColor, 12 | } from './specs/inappbrowser-nitro.nitro' 13 | import { 14 | BrowserColorScheme, 15 | BrowserResultType, 16 | BrowserShareState, 17 | DismissButtonStyle, 18 | ModalPresentationStyle, 19 | ModalTransitionStyle, 20 | StatusBarStyle, 21 | UserInterfaceStyle, 22 | } from './specs/inappbrowser-nitro.nitro' 23 | 24 | InAppBrowser.useInAppBrowser = useInAppBrowser 25 | 26 | export { InAppBrowser, useInAppBrowser } 27 | export { 28 | BrowserColorScheme, 29 | BrowserResultType, 30 | BrowserShareState, 31 | DismissButtonStyle, 32 | ModalPresentationStyle, 33 | ModalTransitionStyle, 34 | StatusBarStyle, 35 | UserInterfaceStyle, 36 | } 37 | export type { 38 | InAppBrowserAuthResult, 39 | InAppBrowserOptions, 40 | InAppBrowserResult, 41 | InAppBrowserAndroidOptions, 42 | InAppBrowserIOSOptions, 43 | BrowserAnimations, 44 | DynamicColor, 45 | } -------------------------------------------------------------------------------- /example/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'daily' 8 | labels: 9 | - 'dependencies' 10 | 11 | - package-ecosystem: 'gradle' 12 | directories: 13 | - '/android/' 14 | - '/example/android/' 15 | schedule: 16 | interval: 'daily' 17 | labels: 18 | - 'nitro-core' 19 | - 'nitrogen' 20 | - 'dependencies' 21 | - 'kotlin' 22 | 23 | - package-ecosystem: 'bundler' 24 | directory: '/example/' 25 | schedule: 26 | interval: 'daily' 27 | labels: 28 | - 'dependencies' 29 | - 'ruby' 30 | 31 | - package-ecosystem: 'npm' 32 | directories: 33 | - '/example/' 34 | - '/' 35 | schedule: 36 | interval: 'daily' 37 | labels: 38 | - 'nitro-core' 39 | - 'dependencies' 40 | - 'typescript' 41 | - 'nitrogen' 42 | 43 | groups: 44 | react-native-cli: 45 | patterns: 46 | - '@react-native-community/cli*' 47 | babel: 48 | patterns: 49 | - '@babel/*' 50 | react-native: 51 | patterns: 52 | - '@react-native/*' 53 | nitro: 54 | patterns: 55 | - 'nitrogen' 56 | - 'react-native-nitro-modules' 57 | -------------------------------------------------------------------------------- /android/src/main/java/com/inappbrowsernitro/browser/BrowserFallback.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitro.browser 2 | 3 | import android.app.Activity 4 | import android.content.ActivityNotFoundException 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.net.Uri 8 | import androidx.core.net.toUri 9 | 10 | internal class BrowserFallback(private val context: Context) { 11 | fun openSystemBrowser(url: String): Boolean { 12 | val uri = url.toUri() 13 | val fallbackIntent = Intent(Intent.ACTION_VIEW, uri) 14 | return launchSafely(fallbackIntent) 15 | } 16 | 17 | fun openChooser(url: String): Boolean { 18 | val uri = url.toUri() 19 | val intent = Intent(Intent.ACTION_VIEW, uri) 20 | val chooser = Intent.createChooser(intent, "Choose browser") 21 | return launchSafely(chooser) 22 | } 23 | 24 | fun redirectToStore(): Boolean { 25 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://search?q=browser")) 26 | return launchSafely(intent) 27 | } 28 | 29 | private fun launchSafely(intent: Intent): Boolean { 30 | if (context !is Activity) { 31 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 32 | } 33 | 34 | return try { 35 | context.startActivity(intent) 36 | true 37 | } catch (_: ActivityNotFoundException) { 38 | false 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-inappbrowser-nitro-example", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "android": "react-native run-android", 7 | "ios": "react-native run-ios --simulator='iPhone 16'", 8 | "lint": "eslint .", 9 | "start": "react-native start --reset-cache", 10 | "test": "jest", 11 | "pod": "bundle install && bundle exec pod install --project-directory=ios" 12 | }, 13 | "dependencies": { 14 | "@react-native/new-app-screen": "0.83.0", 15 | "react": "19.2.3", 16 | "react-native": "0.83.0", 17 | "react-native-nitro-modules": "0.31.10", 18 | "react-native-safe-area-context": "^5.6.2" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "^7.28.5", 22 | "@babel/preset-env": "^7.28.5", 23 | "@babel/runtime": "^7.28.4", 24 | "@react-native-community/cli": "20.0.2", 25 | "@react-native-community/cli-platform-android": "20.0.2", 26 | "@react-native-community/cli-platform-ios": "20.0.2", 27 | "@react-native/babel-preset": "0.83.0", 28 | "@react-native/eslint-config": "0.83.0", 29 | "@react-native/metro-config": "0.83.0", 30 | "@react-native/typescript-config": "0.83.0", 31 | "@types/jest": "^30.0.0", 32 | "babel-plugin-module-resolver": "^5.0.2", 33 | "eslint": "^9.39.2", 34 | "prettier": "^3.7.4" 35 | }, 36 | "engines": { 37 | "node": ">=20" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | **/.xcode.env.local 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | *.hprof 33 | .cxx/ 34 | *.keystore 35 | !debug.keystore 36 | .kotlin/ 37 | 38 | # node.js 39 | # 40 | node_modules/ 41 | npm-debug.log 42 | yarn-error.log 43 | 44 | # fastlane 45 | # 46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 47 | # screenshots whenever they are needed. 48 | # For more information about the recommended setup visit: 49 | # https://docs.fastlane.tools/best-practices/source-control/ 50 | 51 | **/fastlane/report.xml 52 | **/fastlane/Preview.html 53 | **/fastlane/screenshots 54 | **/fastlane/test_output 55 | 56 | # Bundle artifact 57 | *.jsbundle 58 | 59 | # Ruby / CocoaPods 60 | **/Pods/ 61 | /vendor/bundle/ 62 | 63 | # Temporary files created by Metro to check the health of the file watcher 64 | .metro-health-check* 65 | 66 | # testing 67 | /coverage 68 | 69 | # Yarn 70 | .yarn/* 71 | !.yarn/patches 72 | !.yarn/plugins 73 | !.yarn/releases 74 | !.yarn/sdks 75 | !.yarn/versions 76 | -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import React 3 | import React_RCTAppDelegate 4 | import ReactAppDependencyProvider 5 | 6 | @main 7 | class AppDelegate: UIResponder, UIApplicationDelegate { 8 | var window: UIWindow? 9 | 10 | var reactNativeDelegate: ReactNativeDelegate? 11 | var reactNativeFactory: RCTReactNativeFactory? 12 | 13 | func application( 14 | _ application: UIApplication, 15 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 16 | ) -> Bool { 17 | let delegate = ReactNativeDelegate() 18 | let factory = RCTReactNativeFactory(delegate: delegate) 19 | delegate.dependencyProvider = RCTAppDependencyProvider() 20 | 21 | reactNativeDelegate = delegate 22 | reactNativeFactory = factory 23 | 24 | window = UIWindow(frame: UIScreen.main.bounds) 25 | 26 | factory.startReactNative( 27 | withModuleName: "InappbrowserNitroExample", 28 | in: window, 29 | launchOptions: launchOptions 30 | ) 31 | 32 | return true 33 | } 34 | } 35 | 36 | class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { 37 | override func sourceURL(for bridge: RCTBridge) -> URL? { 38 | self.bundleURL() 39 | } 40 | 41 | override func bundleURL() -> URL? { 42 | #if DEBUG 43 | RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") 44 | #else 45 | Bundle.main.url(forResource: "main", withExtension: "jsbundle") 46 | #endif 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ios/HybridInappbrowserNitro.swift: -------------------------------------------------------------------------------- 1 | import NitroModules 2 | 3 | final class HybridInappbrowserNitro: HybridInappbrowserNitroSpec { 4 | private let safariPresenter = SafariPresenter() 5 | private let authManager = AuthSessionManager() 6 | 7 | func isAvailable() throws -> Promise { 8 | Promise.resolved(withResult: true) 9 | } 10 | 11 | func open(url: String, options: InAppBrowserOptions?) throws -> Promise { 12 | Promise.async { [weak self] in 13 | guard let self else { 14 | return InAppBrowserResult(type: .dismiss, url: nil, message: "module released") 15 | } 16 | 17 | return await self.safariPresenter.present(urlString: url, options: options) 18 | } 19 | } 20 | 21 | func openAuth(url: String, redirectUrl: String, options: InAppBrowserOptions?) throws -> Promise { 22 | Promise.async { [weak self] in 23 | guard let self else { 24 | return InAppBrowserAuthResult(type: .dismiss, url: nil, message: "module released") 25 | } 26 | 27 | return await self.authManager.start(urlString: url, redirectUrl: redirectUrl, options: options) 28 | } 29 | } 30 | 31 | func close() throws -> Promise { 32 | Promise.async { [weak self] in 33 | guard let self else { return } 34 | 35 | await self.safariPresenter.dismiss() 36 | } 37 | } 38 | 39 | func closeAuth() throws -> Promise { 40 | Promise.async { [weak self] in 41 | guard let self else { return } 42 | 43 | await self.authManager.cancel() 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | release: 15 | name: Release 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | issues: write 20 | pull-requests: write 21 | id-token: write 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Setup Bun.js 28 | uses: oven-sh/setup-bun@v2 29 | with: 30 | bun-version: latest 31 | - name: Cache bun dependencies 32 | id: bun-cache 33 | uses: actions/cache@v4 34 | with: 35 | path: ~/.bun/install/cache 36 | key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} 37 | restore-keys: | 38 | ${{ runner.os }}-bun- 39 | 40 | - name: Install npm dependencies (bun) 41 | run: bun install 42 | 43 | - name: Build lib 44 | run: bun run build 45 | 46 | - name: Release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | NPM_CONFIG_PROVENANCE: true 51 | GIT_AUTHOR_NAME: ${{ github.actor }} 52 | GIT_AUTHOR_EMAIL: '${{ github.actor }}@users.noreply.github.com' 53 | GIT_COMMITTER_NAME: ${{ github.actor }} 54 | GIT_COMMITTER_EMAIL: '${{ github.actor }}@users.noreply.github.com' 55 | run: bun release 56 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | const rules = [ 2 | { type: 'feat', release: 'minor', title: '✨ Features' }, 3 | { type: 'fix', release: 'patch', title: '🐛 Bug Fixes' }, 4 | { type: 'perf', release: 'patch', title: '💨 Performance Improvements' }, 5 | { type: 'refactor', release: 'patch', title: '🔄 Code Refactors' }, 6 | { type: 'docs', release: 'patch', title: '📚 Documentation' }, 7 | { type: 'chore', release: 'patch', title: '🛠️ Other changes' }, 8 | ] 9 | 10 | const sortMap = Object.fromEntries( 11 | rules.map((rule, index) => [rule.title, index]) 12 | ) 13 | 14 | /** 15 | * @type {import('semantic-release').GlobalConfig} 16 | */ 17 | module.exports = { 18 | branches: ['main', { name: 'next', prerelease: 'next' }], 19 | plugins: [ 20 | [ 21 | '@semantic-release/commit-analyzer', 22 | { 23 | preset: 'conventionalcommits', 24 | releaseRules: [ 25 | { breaking: true, release: 'major' }, 26 | { revert: true, release: 'patch' }, 27 | ].concat(rules.map(({ type, release }) => ({ type, release }))), 28 | }, 29 | ], 30 | [ 31 | '@semantic-release/release-notes-generator', 32 | { 33 | preset: 'conventionalcommits', 34 | presetConfig: { 35 | types: rules.map(({ type, title }) => ({ 36 | type, 37 | section: title, 38 | })), 39 | }, 40 | writerOpts: { 41 | commitGroupsSort: (a, z) => sortMap[a.title] - sortMap[z.title], 42 | }, 43 | }, 44 | ], 45 | [ 46 | '@semantic-release/changelog', 47 | { 48 | changelogFile: 'CHANGELOG.md', 49 | }, 50 | ], 51 | '@semantic-release/npm', 52 | '@semantic-release/github', 53 | [ 54 | '@semantic-release/git', 55 | { 56 | assets: ['package.json', 'CHANGELOG.md', 'example/package.json'], 57 | }, 58 | ], 59 | ], 60 | } 61 | -------------------------------------------------------------------------------- /ios/UIColor+DynamicColor.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIColor { 4 | static func from(dynamicColor: DynamicColor?) -> UIColor? { 5 | guard let dynamicColor else { return nil } 6 | 7 | let base = dynamicColor.base?.nitroColor 8 | let light = dynamicColor.light?.nitroColor 9 | let dark = dynamicColor.dark?.nitroColor 10 | let highContrast = dynamicColor.highContrast?.nitroColor 11 | 12 | if #available(iOS 13.0, *), (light != nil || dark != nil || highContrast != nil) { 13 | return UIColor { traits in 14 | if traits.accessibilityContrast == .high, let highContrast { 15 | return highContrast 16 | } 17 | switch traits.userInterfaceStyle { 18 | case .dark: 19 | return dark ?? base ?? light ?? UIColor.label 20 | default: 21 | return light ?? base ?? dark ?? UIColor.label 22 | } 23 | } 24 | } 25 | 26 | return base ?? light ?? dark ?? highContrast 27 | } 28 | } 29 | 30 | private extension String { 31 | var nitroColor: UIColor? { 32 | var sanitized = nitroTrimmedNonEmpty ?? "" 33 | if sanitized.hasPrefix("#") { 34 | sanitized.removeFirst() 35 | } 36 | 37 | if sanitized.count == 3 || sanitized.count == 4 { 38 | sanitized = sanitized.reduce(into: "") { partial, character in 39 | partial.append(character) 40 | partial.append(character) 41 | } 42 | } 43 | 44 | guard sanitized.count == 6 || sanitized.count == 8 else { 45 | return nil 46 | } 47 | 48 | var value: UInt64 = 0 49 | guard Scanner(string: sanitized).scanHexInt64(&value) else { 50 | return nil 51 | } 52 | 53 | let hasAlpha = sanitized.count == 8 54 | let alpha = hasAlpha ? CGFloat((value & 0xFF000000) >> 24) / 255.0 : 1.0 55 | let red = CGFloat((value & 0x00FF0000) >> 16) / 255.0 56 | let green = CGFloat((value & 0x0000FF00) >> 8) / 255.0 57 | let blue = CGFloat(value & 0x000000FF) / 255.0 58 | 59 | return UIColor(red: red, green: green, blue: blue, alpha: alpha) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/drawable/rn_edit_text_material.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 22 | 23 | 24 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleDisplayName 10 | InappbrowserNitroExample 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleSignature 24 | ???? 25 | CFBundleVersion 26 | $(CURRENT_PROJECT_VERSION) 27 | LSRequiresIPhoneOS 28 | 29 | NSAppTransportSecurity 30 | 31 | NSAllowsArbitraryLoads 32 | 33 | NSAllowsLocalNetworking 34 | 35 | 36 | NSLocationWhenInUseUsageDescription 37 | 38 | RCTNewArchEnabled 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | arm64 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UIViewControllerBasedStatusBarAppearance 53 | 54 | CFBundleURLTypes 55 | 56 | 57 | CFBundleURLSchemes 58 | 59 | inappbrowsernitro 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /example/android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m 13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | org.gradle.parallel=true 19 | 20 | # AndroidX package structure to make it clearer which packages are bundled with the 21 | # Android operating system, and which are packaged with your app's APK 22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 23 | android.useAndroidX=true 24 | 25 | # Use this property to specify which architecture you want to build. 26 | # You can also override it from the CLI using 27 | # ./gradlew -PreactNativeArchitectures=x86_64 28 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 29 | 30 | # Use this property to enable support to the new architecture. 31 | # This will allow you to use TurboModules and the Fabric render in 32 | # your application. You should enable this flag either if you want 33 | # to write custom TurboModules/Fabric components OR use libraries that 34 | # are providing them. 35 | newArchEnabled=true 36 | 37 | # Use this property to enable or disable the Hermes JS engine. 38 | # If set to false, you will be using JSC instead. 39 | hermesEnabled=true 40 | 41 | # Use this property to enable edge-to-edge display support. 42 | # This allows your app to draw behind system bars for an immersive UI. 43 | # Note: Only works with ReactActivity and should not be used with custom Activity. 44 | edgeToEdgeEnabled=false 45 | -------------------------------------------------------------------------------- /.github/workflows/android-build.yml: -------------------------------------------------------------------------------- 1 | name: Build Android 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - '.github/workflows/android-build.yml' 12 | - 'example/android/**' 13 | - 'nitrogen/generated/shared/**' 14 | - 'nitrogen/generated/android/**' 15 | - 'cpp/**' 16 | - 'android/**' 17 | - '**/bun.lock' 18 | - '**/react-native.config.js' 19 | - '**/nitro.json' 20 | pull_request: 21 | paths: 22 | - '.github/workflows/android-build.yml' 23 | - 'example/android/**' 24 | - '**/nitrogen/generated/shared/**' 25 | - '**/nitrogen/generated/android/**' 26 | - 'cpp/**' 27 | - 'android/**' 28 | - '**/bun.lock' 29 | - '**/react-native.config.js' 30 | - '**/nitro.json' 31 | workflow_dispatch: 32 | 33 | concurrency: 34 | group: ${{ github.workflow }}-${{ github.ref }} 35 | cancel-in-progress: true 36 | 37 | jobs: 38 | build: 39 | name: Build Android Example App 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: '20' 46 | 47 | - name: Install dependencies (yarn) 48 | run: yarn install --immutable 49 | 50 | - name: Generate Nitro bindings 51 | run: yarn codegen 52 | 53 | - name: Setup JDK 17 54 | uses: actions/setup-java@v5 55 | with: 56 | distribution: 'zulu' 57 | java-version: '17' 58 | cache: 'gradle' 59 | 60 | - name: Cache Gradle 61 | uses: actions/cache@v4 62 | with: 63 | path: | 64 | ~/.gradle/caches 65 | ~/.gradle/wrapper 66 | key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/**/*.gradle*') }} 67 | restore-keys: | 68 | ${{ runner.os }}-gradle- 69 | 70 | - name: Run Gradle build 71 | working-directory: example/android 72 | run: ./gradlew assembleDebug --no-daemon --build-cache 73 | 74 | - name: Stop Gradle daemon 75 | working-directory: example/android 76 | run: ./gradlew --stop 77 | -------------------------------------------------------------------------------- /android/src/main/java/com/inappbrowsernitro/browser/DynamicColorResolver.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitro.browser 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.view.accessibility.AccessibilityManager 6 | import androidx.core.content.getSystemService 7 | import com.margelo.nitro.inappbrowsernitro.DynamicColor 8 | 9 | internal object DynamicColorResolver { 10 | fun resolve(context: Context, dynamicColor: DynamicColor?): Int? { 11 | dynamicColor ?: return null 12 | 13 | val accessibilityManager = context.getSystemService() 14 | val isHighContrast = accessibilityManager?.let { manager -> 15 | runCatching { 16 | val method = AccessibilityManager::class.java.getMethod("isHighTextContrastEnabled") 17 | (method.invoke(manager) as? Boolean) == true 18 | }.getOrDefault(false) 19 | } == true 20 | 21 | val isDark = (context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES 22 | 23 | val candidate = when { 24 | isHighContrast && dynamicColor.highContrast != null -> dynamicColor.highContrast 25 | isDark && dynamicColor.dark != null -> dynamicColor.dark 26 | !isDark && dynamicColor.light != null -> dynamicColor.light 27 | dynamicColor.base != null -> dynamicColor.base 28 | else -> dynamicColor.dark ?: dynamicColor.light ?: dynamicColor.highContrast 29 | } 30 | 31 | return candidate?.let(::parseColorSafely) 32 | } 33 | 34 | fun resolveForScheme(dynamicColor: DynamicColor?, preferred: DynamicScheme): Int? { 35 | dynamicColor ?: return null 36 | val hex = when (preferred) { 37 | DynamicScheme.SYSTEM -> dynamicColor.base ?: dynamicColor.light ?: dynamicColor.dark ?: dynamicColor.highContrast 38 | DynamicScheme.LIGHT -> dynamicColor.light ?: dynamicColor.base 39 | DynamicScheme.DARK -> dynamicColor.dark ?: dynamicColor.base 40 | } 41 | return hex?.let(::parseColorSafely) 42 | } 43 | 44 | private fun parseColorSafely(value: String): Int? { 45 | return try { 46 | Color.parseColor(value.trim()) 47 | } catch (_: IllegalArgumentException) { 48 | null 49 | } 50 | } 51 | 52 | enum class DynamicScheme { SYSTEM, LIGHT, DARK } 53 | } 54 | -------------------------------------------------------------------------------- /android/fix-prefab.gradle: -------------------------------------------------------------------------------- 1 | tasks.configureEach { task -> 2 | // Make sure that we generate our prefab publication file only after having built the native library 3 | // so that not a header publication file, but a full configuration publication will be generated, which 4 | // will include the .so file 5 | 6 | def prefabConfigurePattern = ~/^prefab(.+)ConfigurePackage$/ 7 | def matcher = task.name =~ prefabConfigurePattern 8 | if (matcher.matches()) { 9 | def variantName = matcher[0][1] 10 | task.outputs.upToDateWhen { false } 11 | task.dependsOn("externalNativeBuild${variantName}") 12 | } 13 | } 14 | 15 | afterEvaluate { 16 | def abis = reactNativeArchitectures() 17 | rootProject.allprojects.each { proj -> 18 | if (proj === rootProject) return 19 | 20 | def dependsOnThisLib = proj.configurations.findAll { it.canBeResolved }.any { config -> 21 | config.dependencies.any { dep -> 22 | dep.group == project.group && dep.name == project.name 23 | } 24 | } 25 | if (!dependsOnThisLib && proj != project) return 26 | 27 | if (!proj.plugins.hasPlugin('com.android.application') && !proj.plugins.hasPlugin('com.android.library')) { 28 | return 29 | } 30 | 31 | def variants = proj.android.hasProperty('applicationVariants') ? proj.android.applicationVariants : proj.android.libraryVariants 32 | // Touch the prefab_config.json files to ensure that in ExternalNativeJsonGenerator.kt we will re-trigger the prefab CLI to 33 | // generate a libnameConfig.cmake file that will contain our native library (.so). 34 | // See this condition: https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/ExternalNativeJsonGenerator.kt;l=207-219?q=createPrefabBuildSystemGlue 35 | variants.all { variant -> 36 | def variantName = variant.name 37 | abis.each { abi -> 38 | def searchDir = new File(proj.projectDir, ".cxx/${variantName}") 39 | if (!searchDir.exists()) return 40 | def matches = [] 41 | searchDir.eachDir { randomDir -> 42 | def prefabFile = new File(randomDir, "${abi}/prefab_config.json") 43 | if (prefabFile.exists()) matches << prefabFile 44 | } 45 | matches.each { prefabConfig -> 46 | prefabConfig.setLastModified(System.currentTimeMillis()) 47 | } 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/core/InAppBrowser.ts: -------------------------------------------------------------------------------- 1 | import { NitroModules } from 'react-native-nitro-modules' 2 | 3 | import type { 4 | InappbrowserNitro, 5 | InAppBrowserAuthResult, 6 | InAppBrowserOptions, 7 | InAppBrowserResult, 8 | } from '../specs/inappbrowser-nitro.nitro' 9 | import type { UseInAppBrowserReturn } from '../hooks/useInAppBrowser' 10 | import { normalizeOptions } from '../utils/options' 11 | import { normalizeUrl } from '../utils/url' 12 | 13 | let cachedModule: InappbrowserNitro | null = null 14 | 15 | const getNativeModule = (): InappbrowserNitro => { 16 | if (!cachedModule) { 17 | cachedModule = NitroModules.createHybridObject( 18 | 'InappbrowserNitro' 19 | ) 20 | } 21 | 22 | return cachedModule 23 | } 24 | 25 | const mapOptions = (options?: InAppBrowserOptions) => normalizeOptions(options) 26 | 27 | /** 28 | * Public imperative API for the in-app browser Nitro module. 29 | */ 30 | export class InAppBrowser { 31 | /** Optional React hook injector (populated in index.ts). */ 32 | static useInAppBrowser?: () => UseInAppBrowserReturn 33 | 34 | /** 35 | * Return whether the current device/runtime can present the in-app browser. 36 | */ 37 | static async isAvailable(): Promise { 38 | return getNativeModule().isAvailable() 39 | } 40 | 41 | /** 42 | * Launch the in-app browser with the provided URL and options. 43 | */ 44 | static async open( 45 | url: string, 46 | options?: InAppBrowserOptions 47 | ): Promise { 48 | const sanitizedUrl = normalizeUrl(url) 49 | const sanitizedOptions = mapOptions(options) 50 | return getNativeModule().open(sanitizedUrl, sanitizedOptions) 51 | } 52 | 53 | /** 54 | * Launch the authentication browser flow, resolving with the redirect payload. 55 | */ 56 | static async openAuth( 57 | url: string, 58 | redirectUrl: string, 59 | options?: InAppBrowserOptions 60 | ): Promise { 61 | const sanitizedUrl = normalizeUrl(url) 62 | 63 | const sanitizedRedirect = normalizeUrl(redirectUrl) 64 | 65 | const sanitizedOptions = mapOptions(options) 66 | 67 | return getNativeModule().openAuth( 68 | sanitizedUrl, 69 | sanitizedRedirect, 70 | sanitizedOptions 71 | ) 72 | } 73 | 74 | /** 75 | * Close the currently visible browser instance. 76 | */ 77 | static async close(): Promise { 78 | return getNativeModule().close() 79 | } 80 | 81 | /** 82 | * Close the currently active authentication session. 83 | */ 84 | static async closeAuth(): Promise { 85 | return getNativeModule().closeAuth() 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/hooks/useInAppBrowser.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | 3 | import { InAppBrowser } from '../core/InAppBrowser' 4 | import type { 5 | InAppBrowserAuthResult, 6 | InAppBrowserOptions, 7 | InAppBrowserResult, 8 | } from '../specs/inappbrowser-nitro.nitro' 9 | 10 | export interface UseInAppBrowserReturn { 11 | open: ( 12 | url: string, 13 | options?: InAppBrowserOptions 14 | ) => Promise 15 | openAuth: ( 16 | url: string, 17 | redirectUrl: string, 18 | options?: InAppBrowserOptions 19 | ) => Promise 20 | close: () => Promise 21 | closeAuth: () => Promise 22 | isAvailable: () => Promise 23 | isLoading: boolean 24 | error: Error | null 25 | } 26 | 27 | /** 28 | * React hook that wraps the imperative API with loading/error tracking. 29 | */ 30 | export function useInAppBrowser(): UseInAppBrowserReturn { 31 | const isMountedRef = useRef(true) 32 | 33 | const [isLoading, setIsLoading] = useState(false) 34 | 35 | const [error, setError] = useState(null) 36 | 37 | const runSafely = useCallback(async (operation: () => Promise) => { 38 | setIsLoading(true) 39 | setError(null) 40 | 41 | try { 42 | const result = await operation() 43 | 44 | if (isMountedRef.current) { 45 | setIsLoading(false) 46 | } 47 | 48 | return result 49 | } catch (err) { 50 | const currentError = err instanceof Error ? err : new Error(String(err)) 51 | if (isMountedRef.current) { 52 | setError(currentError) 53 | setIsLoading(false) 54 | } 55 | throw currentError 56 | } 57 | }, []) 58 | 59 | useEffect(() => { 60 | return () => { 61 | isMountedRef.current = false 62 | } 63 | }, []) 64 | 65 | const open = useCallback( 66 | (url: string, options?: InAppBrowserOptions) => 67 | runSafely(() => InAppBrowser.open(url, options)), 68 | [runSafely] 69 | ) 70 | 71 | const openAuth = useCallback( 72 | (url: string, redirectUrl: string, options?: InAppBrowserOptions) => 73 | runSafely(() => InAppBrowser.openAuth(url, redirectUrl, options)), 74 | [runSafely] 75 | ) 76 | 77 | const close = useCallback(() => runSafely(() => InAppBrowser.close()), [runSafely]) 78 | 79 | const closeAuth = useCallback( 80 | () => runSafely(() => InAppBrowser.closeAuth()), 81 | [runSafely] 82 | ) 83 | 84 | const isAvailable = useCallback( 85 | () => runSafely(() => InAppBrowser.isAvailable()), 86 | [runSafely] 87 | ) 88 | 89 | return { 90 | open, 91 | openAuth, 92 | close, 93 | closeAuth, 94 | isAvailable, 95 | isLoading, 96 | error, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /.github/workflows/ios-build.yml: -------------------------------------------------------------------------------- 1 | name: Build iOS 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - '.github/workflows/ios-build.yml' 12 | - 'example/ios/**' 13 | - 'example/Gemfile' 14 | - 'example/Gemfile.lock' 15 | - '**/nitrogen/generated/shared/**' 16 | - '**/nitrogen/generated/ios/**' 17 | - 'cpp/**' 18 | - 'ios/**' 19 | - '**/Podfile.lock' 20 | - '**/bun.lock' 21 | - '**/*.podspec' 22 | - '**/react-native.config.js' 23 | - '**/nitro.json' 24 | pull_request: 25 | paths: 26 | - '.github/workflows/ios-build.yml' 27 | - 'example/ios/**' 28 | - 'example/Gemfile' 29 | - 'example/Gemfile.lock' 30 | - '**/nitrogen/generated/shared/**' 31 | - '**/nitrogen/generated/ios/**' 32 | - 'cpp/**' 33 | - 'ios/**' 34 | - '**/Podfile.lock' 35 | - '**/bun.lock' 36 | - '**/*.podspec' 37 | - '**/react-native.config.js' 38 | - '**/nitro.json' 39 | workflow_dispatch: 40 | 41 | env: 42 | USE_CCACHE: 1 43 | 44 | concurrency: 45 | group: ${{ github.workflow }}-${{ github.ref }} 46 | cancel-in-progress: true 47 | 48 | jobs: 49 | build: 50 | name: Build iOS Example App 51 | runs-on: macOS-15 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version: '20' 57 | - name: Setup Xcode 58 | uses: maxim-lobanov/setup-xcode@v1 59 | with: 60 | xcode-version: 16.4 61 | 62 | - name: Install dependencies (yarn) 63 | run: yarn install --immutable 64 | 65 | - name: Generate Nitro bindings 66 | run: yarn codegen 67 | 68 | - name: Setup Ruby (bundle) 69 | uses: ruby/setup-ruby@v1 70 | with: 71 | ruby-version: '3.2' 72 | bundler-cache: true 73 | working-directory: example/ios 74 | 75 | - name: Install xcpretty 76 | run: gem install xcpretty 77 | 78 | - name: Cache CocoaPods 79 | uses: actions/cache@v4 80 | with: 81 | path: | 82 | ~/.cocoapods/repos 83 | example/ios/Pods 84 | key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile.lock') }} 85 | restore-keys: | 86 | ${{ runner.os }}-pods- 87 | 88 | - name: Install Pods 89 | working-directory: example/ios 90 | run: pod install 91 | 92 | - name: Build App 93 | working-directory: example/ios 94 | run: | 95 | set -o pipefail && xcodebuild \ 96 | CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ 97 | -derivedDataPath build -UseModernBuildSystem=YES \ 98 | -workspace InappbrowserNitroExample.xcworkspace \ 99 | -scheme InappbrowserNitroExample \ 100 | -sdk iphonesimulator \ 101 | -configuration Debug \ 102 | -destination 'platform=iOS Simulator,name=iPhone 16' \ 103 | build \ 104 | CODE_SIGNING_ALLOWED=NO | xcpretty 105 | -------------------------------------------------------------------------------- /ios/AuthSessionManager.swift: -------------------------------------------------------------------------------- 1 | import AuthenticationServices 2 | import SafariServices 3 | 4 | final class AuthSessionManager: NSObject { 5 | private var session: AuthenticationSession? 6 | 7 | @MainActor 8 | func start(urlString: String, redirectUrl: String, options: InAppBrowserOptions?) async -> InAppBrowserAuthResult { 9 | guard let url = URL(string: urlString) else { 10 | return InAppBrowserAuthResult(type: .dismiss, url: nil, message: "invalid url") 11 | } 12 | 13 | let callbackScheme = URL(string: redirectUrl)?.scheme ?? redirectUrl 14 | 15 | return await withCheckedContinuation { continuation in 16 | if #available(iOS 12.0, *) { 17 | let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in 18 | continuation.resume(returning: self?.mapAuthResult(callbackURL: callbackURL, error: error) ?? Self.genericFailure) 19 | } 20 | 21 | if #available(iOS 13.0, *) { 22 | session.presentationContextProvider = AuthPresentationContextProvider() 23 | session.prefersEphemeralWebBrowserSession = options?.ephemeralWebSession ?? false 24 | } 25 | 26 | // iOS Simulator does not fully emulate Secure Enclave behaviour; expect reduced isolation for ephemeral sessions. 27 | self.session = .asWeb(session) 28 | session.start() 29 | } else { 30 | let session = SFAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in 31 | continuation.resume(returning: self?.mapAuthResult(callbackURL: callbackURL, error: error) ?? Self.genericFailure) 32 | } 33 | self.session = .sf(session) 34 | session.start() 35 | } 36 | } 37 | } 38 | 39 | @MainActor 40 | func cancel() { 41 | switch session { 42 | case .asWeb(let session): 43 | session.cancel() 44 | case .sf(let session): 45 | session.cancel() 46 | case .none: 47 | break 48 | } 49 | session = nil 50 | } 51 | 52 | private func mapAuthResult(callbackURL: URL?, error: Error?) -> InAppBrowserAuthResult { 53 | if let error { 54 | if #available(iOS 12.0, *), let authError = error as? ASWebAuthenticationSessionError, authError.code == .canceledLogin { 55 | return InAppBrowserAuthResult(type: .cancel, url: nil, message: nil) 56 | } 57 | if #available(iOS 11.0, *), let authError = error as? SFAuthenticationError, authError.code == .canceledLogin { 58 | return InAppBrowserAuthResult(type: .cancel, url: nil, message: nil) 59 | } 60 | return InAppBrowserAuthResult(type: .dismiss, url: nil, message: error.localizedDescription) 61 | } 62 | 63 | if let url = callbackURL?.absoluteString { 64 | return InAppBrowserAuthResult(type: .success, url: url, message: nil) 65 | } 66 | 67 | return Self.genericFailure 68 | } 69 | 70 | private static let genericFailure = InAppBrowserAuthResult(type: .dismiss, url: nil, message: "authentication failed") 71 | } 72 | 73 | @available(iOS 13.0, *) 74 | private final class AuthPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { 75 | func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 76 | UIApplication.shared.nitroTopMostViewController?.view.window ?? UIWindow() 77 | } 78 | } 79 | 80 | private enum AuthenticationSession { 81 | case asWeb(ASWebAuthenticationSession) 82 | case sf(SFAuthenticationSession) 83 | } 84 | -------------------------------------------------------------------------------- /example/android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @REM Copyright (c) Meta Platforms, Inc. and affiliates. 2 | @REM 3 | @REM This source code is licensed under the MIT license found in the 4 | @REM LICENSE file in the root directory of this source tree. 5 | 6 | @rem 7 | @rem Copyright 2015 the original author or authors. 8 | @rem 9 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 10 | @rem you may not use this file except in compliance with the License. 11 | @rem You may obtain a copy of the License at 12 | @rem 13 | @rem https://www.apache.org/licenses/LICENSE-2.0 14 | @rem 15 | @rem Unless required by applicable law or agreed to in writing, software 16 | @rem distributed under the License is distributed on an "AS IS" BASIS, 17 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | @rem See the License for the specific language governing permissions and 19 | @rem limitations under the License. 20 | @rem 21 | @rem SPDX-License-Identifier: Apache-2.0 22 | @rem 23 | 24 | @if "%DEBUG%"=="" @echo off 25 | @rem ########################################################################## 26 | @rem 27 | @rem Gradle startup script for Windows 28 | @rem 29 | @rem ########################################################################## 30 | 31 | @rem Set local scope for the variables with windows NT shell 32 | if "%OS%"=="Windows_NT" setlocal 33 | 34 | set DIRNAME=%~dp0 35 | if "%DIRNAME%"=="" set DIRNAME=. 36 | @rem This is normally unused 37 | set APP_BASE_NAME=%~n0 38 | set APP_HOME=%DIRNAME% 39 | 40 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 41 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 42 | 43 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 44 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 45 | 46 | @rem Find java.exe 47 | if defined JAVA_HOME goto findJavaFromJavaHome 48 | 49 | set JAVA_EXE=java.exe 50 | %JAVA_EXE% -version >NUL 2>&1 51 | if %ERRORLEVEL% equ 0 goto execute 52 | 53 | echo. 1>&2 54 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 55 | echo. 1>&2 56 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 57 | echo location of your Java installation. 1>&2 58 | 59 | goto fail 60 | 61 | :findJavaFromJavaHome 62 | set JAVA_HOME=%JAVA_HOME:"=% 63 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 64 | 65 | if exist "%JAVA_EXE%" goto execute 66 | 67 | echo. 1>&2 68 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 69 | echo. 1>&2 70 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 71 | echo location of your Java installation. 1>&2 72 | 73 | goto fail 74 | 75 | :execute 76 | @rem Setup the command line 77 | 78 | set CLASSPATH= 79 | 80 | 81 | @rem Execute Gradle 82 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 83 | 84 | :end 85 | @rem End local scope for the variables with windows NT shell 86 | if %ERRORLEVEL% equ 0 goto mainEnd 87 | 88 | :fail 89 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 90 | rem the _cmd.exe /c_ return code! 91 | set EXIT_CODE=%ERRORLEVEL% 92 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 93 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 94 | exit /b %EXIT_CODE% 95 | 96 | :mainEnd 97 | if "%OS%"=="Windows_NT" endlocal 98 | 99 | :omega 100 | -------------------------------------------------------------------------------- /example/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | activesupport (7.2.2.2) 9 | base64 10 | benchmark (>= 0.3) 11 | bigdecimal 12 | concurrent-ruby (~> 1.0, >= 1.3.1) 13 | connection_pool (>= 2.2.5) 14 | drb 15 | i18n (>= 1.6, < 2) 16 | logger (>= 1.4.2) 17 | minitest (>= 5.1) 18 | securerandom (>= 0.3) 19 | tzinfo (~> 2.0, >= 2.0.5) 20 | addressable (2.8.7) 21 | public_suffix (>= 2.0.2, < 7.0) 22 | algoliasearch (1.27.5) 23 | httpclient (~> 2.8, >= 2.8.3) 24 | json (>= 1.5.1) 25 | atomos (0.1.3) 26 | base64 (0.3.0) 27 | benchmark (0.4.1) 28 | bigdecimal (3.3.1) 29 | claide (1.1.0) 30 | cocoapods (1.15.2) 31 | addressable (~> 2.8) 32 | claide (>= 1.0.2, < 2.0) 33 | cocoapods-core (= 1.15.2) 34 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 35 | cocoapods-downloader (>= 2.1, < 3.0) 36 | cocoapods-plugins (>= 1.0.0, < 2.0) 37 | cocoapods-search (>= 1.0.0, < 2.0) 38 | cocoapods-trunk (>= 1.6.0, < 2.0) 39 | cocoapods-try (>= 1.1.0, < 2.0) 40 | colored2 (~> 3.1) 41 | escape (~> 0.0.4) 42 | fourflusher (>= 2.3.0, < 3.0) 43 | gh_inspector (~> 1.0) 44 | molinillo (~> 0.8.0) 45 | nap (~> 1.0) 46 | ruby-macho (>= 2.3.0, < 3.0) 47 | xcodeproj (>= 1.23.0, < 2.0) 48 | cocoapods-core (1.15.2) 49 | activesupport (>= 5.0, < 8) 50 | addressable (~> 2.8) 51 | algoliasearch (~> 1.0) 52 | concurrent-ruby (~> 1.1) 53 | fuzzy_match (~> 2.0.4) 54 | nap (~> 1.0) 55 | netrc (~> 0.11) 56 | public_suffix (~> 4.0) 57 | typhoeus (~> 1.0) 58 | cocoapods-deintegrate (1.0.5) 59 | cocoapods-downloader (2.1) 60 | cocoapods-plugins (1.0.0) 61 | nap 62 | cocoapods-search (1.0.1) 63 | cocoapods-trunk (1.6.0) 64 | nap (>= 0.8, < 2.0) 65 | netrc (~> 0.11) 66 | cocoapods-try (1.2.0) 67 | colored2 (3.1.2) 68 | concurrent-ruby (1.3.3) 69 | connection_pool (2.5.4) 70 | drb (2.2.3) 71 | escape (0.0.4) 72 | ethon (0.15.0) 73 | ffi (>= 1.15.0) 74 | ffi (1.17.2) 75 | fourflusher (2.3.1) 76 | fuzzy_match (2.0.4) 77 | gh_inspector (1.1.3) 78 | httpclient (2.9.0) 79 | mutex_m 80 | i18n (1.14.7) 81 | concurrent-ruby (~> 1.0) 82 | json (2.15.1) 83 | logger (1.7.0) 84 | minitest (5.26.0) 85 | molinillo (0.8.0) 86 | mutex_m (0.3.0) 87 | nanaimo (0.3.0) 88 | nap (1.1.0) 89 | netrc (0.11.0) 90 | nkf (0.2.0) 91 | public_suffix (4.0.7) 92 | rexml (3.4.4) 93 | ruby-macho (2.5.1) 94 | securerandom (0.4.1) 95 | typhoeus (1.5.0) 96 | ethon (>= 0.9.0, < 0.16.0) 97 | tzinfo (2.0.6) 98 | concurrent-ruby (~> 1.0) 99 | xcodeproj (1.25.1) 100 | CFPropertyList (>= 2.3.3, < 4.0) 101 | atomos (~> 0.1.3) 102 | claide (>= 1.0.2, < 2.0) 103 | colored2 (~> 3.1) 104 | nanaimo (~> 0.3.0) 105 | rexml (>= 3.3.6, < 4.0) 106 | 107 | PLATFORMS 108 | ruby 109 | 110 | DEPENDENCIES 111 | activesupport (>= 6.1.7.5, != 7.1.0) 112 | benchmark 113 | bigdecimal 114 | cocoapods (>= 1.13, != 1.15.1, != 1.15.0) 115 | concurrent-ruby (< 1.3.4) 116 | logger 117 | mutex_m 118 | xcodeproj (< 1.26.0) 119 | 120 | RUBY VERSION 121 | ruby 3.4.7p58 122 | 123 | BUNDLED WITH 124 | 2.7.2 125 | -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample.xcodeproj/xcshareddata/xcschemes/InAppBrowserNitroExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). 2 | 3 | # Getting Started 4 | 5 | > **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. 6 | 7 | ## Step 1: Start Metro 8 | 9 | First, you will need to run **Metro**, the JavaScript build tool for React Native. 10 | 11 | To start the Metro dev server, run the following command from the root of your React Native project: 12 | 13 | ```sh 14 | # Using npm 15 | npm start 16 | 17 | # OR using Yarn 18 | yarn start 19 | ``` 20 | 21 | ## Step 2: Build and run your app 22 | 23 | With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: 24 | 25 | ### Android 26 | 27 | ```sh 28 | # Using npm 29 | npm run android 30 | 31 | # OR using Yarn 32 | yarn android 33 | ``` 34 | 35 | ### iOS 36 | 37 | For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). 38 | 39 | The first time you create a new project, run the Ruby bundler to install CocoaPods itself: 40 | 41 | ```sh 42 | bundle install 43 | ``` 44 | 45 | Then, and every time you update your native dependencies, run: 46 | 47 | ```sh 48 | bundle exec pod install 49 | ``` 50 | 51 | For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). 52 | 53 | ```sh 54 | # Using npm 55 | npm run ios 56 | 57 | # OR using Yarn 58 | yarn ios 59 | ``` 60 | 61 | If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. 62 | 63 | This is one way to run your app — you can also build it directly from Android Studio or Xcode. 64 | 65 | ## Step 3: Modify your app 66 | 67 | Now that you have successfully run the app, let's make changes! 68 | 69 | Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). 70 | 71 | When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: 72 | 73 | - **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). 74 | - **iOS**: Press R in iOS Simulator. 75 | 76 | ## Congratulations! :tada: 77 | 78 | You've successfully run and modified your React Native App. :partying_face: 79 | 80 | ### Now what? 81 | 82 | - If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). 83 | - If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). 84 | 85 | # Troubleshooting 86 | 87 | If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. 88 | 89 | # Learn More 90 | 91 | To learn more about React Native, take a look at the following resources: 92 | 93 | - [React Native Website](https://reactnative.dev) - learn more about React Native. 94 | - [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. 95 | - [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. 96 | - [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. 97 | - [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-inappbrowser-nitro", 3 | "version": "2.1.2", 4 | "description": "react-native-inappbrowser-nitro is a react native package built with Nitro", 5 | "main": "./lib/commonjs/index.js", 6 | "module": "./lib/module/index.js", 7 | "types": "./lib/typescript/src/index.d.ts", 8 | "react-native": "src/index", 9 | "source": "src/index", 10 | "scripts": { 11 | "typecheck": "tsc --noEmit", 12 | "clean": "git clean -dfX", 13 | "release": "semantic-release", 14 | "build": "npm run typecheck && bob build && node ./scripts/remove-source-maps.cjs", 15 | "codegen": "nitrogen --logLevel=\"debug\" && npm run build && node post-script.js", 16 | "prepublish": "yarn codegen" 17 | }, 18 | "keywords": [ 19 | "react-native", 20 | "in-app-browser", 21 | "custom-tabs", 22 | "safari-view-controller", 23 | "webview", 24 | "browser", 25 | "nitro", 26 | "android", 27 | "ios", 28 | "oauth", 29 | "authentication", 30 | "sso", 31 | "chrome-custom-tabs", 32 | "safari", 33 | "react-native-inappbrowser-nitro" 34 | ], 35 | "files": [ 36 | "src", 37 | "react-native.config.js", 38 | "lib", 39 | "nitrogen", 40 | "cpp", 41 | "nitro.json", 42 | "android/build.gradle", 43 | "android/fix-prefab.gradle", 44 | "android/gradle.properties", 45 | "android/CMakeLists.txt", 46 | "android/src", 47 | "ios/**/*.h", 48 | "ios/**/*.m", 49 | "ios/**/*.mm", 50 | "ios/**/*.cpp", 51 | "ios/**/*.swift", 52 | "app.plugin.js", 53 | "*.podspec", 54 | "README.md" 55 | ], 56 | "workspaces": [ 57 | "example" 58 | ], 59 | "repository": "https://github.com/mCodex/react-native-inappbrowser-nitro.git", 60 | "author": "Mateus Andrade", 61 | "license": "MIT", 62 | "bugs": "https://github.com/mCodex/react-native-inappbrowser-nitro/issues", 63 | "homepage": "https://github.com/mCodex/react-native-inappbrowser-nitro#readme", 64 | "publishConfig": { 65 | "access": "public", 66 | "registry": "https://registry.npmjs.org/" 67 | }, 68 | "devDependencies": { 69 | "@jamesacarr/eslint-formatter-github-actions": "^0.2.0", 70 | "@semantic-release/changelog": "^6.0.3", 71 | "@semantic-release/git": "^10.0.1", 72 | "@types/jest": "^30.0.0", 73 | "@types/react": "19.2.7", 74 | "conventional-changelog-conventionalcommits": "^9.1.0", 75 | "nitrogen": "0.31.10", 76 | "react": "19.2.3", 77 | "react-native": "0.83", 78 | "react-native-builder-bob": "^0.40.17", 79 | "react-native-nitro-modules": "0.31.10", 80 | "semantic-release": "^25.0.2", 81 | "typescript": "^5.9.3" 82 | }, 83 | "peerDependencies": { 84 | "react": "*", 85 | "react-native": "*", 86 | "react-native-nitro-modules": "*" 87 | }, 88 | "eslintConfig": { 89 | "root": true, 90 | "extends": [ 91 | "@react-native", 92 | "prettier" 93 | ], 94 | "plugins": [ 95 | "prettier" 96 | ], 97 | "rules": { 98 | "prettier/prettier": [ 99 | "warn", 100 | { 101 | "quoteProps": "consistent", 102 | "singleQuote": true, 103 | "tabWidth": 2, 104 | "trailingComma": "es5", 105 | "useTabs": false 106 | } 107 | ] 108 | } 109 | }, 110 | "eslintIgnore": [ 111 | "node_modules/", 112 | "lib/" 113 | ], 114 | "prettier": { 115 | "quoteProps": "consistent", 116 | "singleQuote": true, 117 | "tabWidth": 2, 118 | "trailingComma": "es5", 119 | "useTabs": false, 120 | "semi": false 121 | }, 122 | "react-native-builder-bob": { 123 | "source": "src", 124 | "output": "lib", 125 | "targets": [ 126 | "commonjs", 127 | "module", 128 | [ 129 | "typescript", 130 | { 131 | "project": "tsconfig.json" 132 | } 133 | ] 134 | ] 135 | }, 136 | "packageManager": "yarn@4.11.0" 137 | } 138 | -------------------------------------------------------------------------------- /src/utils/options.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BrowserAnimations, 3 | DynamicColor, 4 | InAppBrowserOptions, 5 | } from '../specs/inappbrowser-nitro.nitro' 6 | 7 | const COLOR_OPTION_KEYS = new Set([ 8 | 'preferredBarTintColor', 9 | 'preferredControlTintColor', 10 | 'toolbarColor', 11 | 'secondaryToolbarColor', 12 | 'navigationBarColor', 13 | 'navigationBarDividerColor', 14 | ]) 15 | 16 | const sanitizeColor = (value: DynamicColor | string | undefined) => { 17 | if (!value) { 18 | return undefined 19 | } 20 | 21 | if (typeof value === 'string') { 22 | const trimmed = value.trim() 23 | return trimmed ? { base: trimmed } : undefined 24 | } 25 | 26 | const payload: DynamicColor = {} 27 | 28 | if (value.base?.trim()) { 29 | payload.base = value.base.trim() 30 | } 31 | if (value.light?.trim()) { 32 | payload.light = value.light.trim() 33 | } 34 | if (value.dark?.trim()) { 35 | payload.dark = value.dark.trim() 36 | } 37 | if (value.highContrast?.trim()) { 38 | payload.highContrast = value.highContrast.trim() 39 | } 40 | 41 | return Object.keys(payload).length > 0 ? payload : undefined 42 | } 43 | 44 | const sanitizeAnimations = (animations?: BrowserAnimations) => { 45 | if (!animations) { 46 | return undefined 47 | } 48 | 49 | const sanitized: BrowserAnimations = {} 50 | if (animations.startEnter?.trim()) { 51 | sanitized.startEnter = animations.startEnter.trim() 52 | } 53 | if (animations.startExit?.trim()) { 54 | sanitized.startExit = animations.startExit.trim() 55 | } 56 | if (animations.endEnter?.trim()) { 57 | sanitized.endEnter = animations.endEnter.trim() 58 | } 59 | if (animations.endExit?.trim()) { 60 | sanitized.endExit = animations.endExit.trim() 61 | } 62 | 63 | return Object.keys(sanitized).length > 0 ? sanitized : undefined 64 | } 65 | 66 | const sanitizeHeaders = (headers?: Record) => { 67 | if (!headers) { 68 | return undefined 69 | } 70 | 71 | const sanitized: Record = {} 72 | for (const [key, currentValue] of Object.entries(headers)) { 73 | if (typeof currentValue !== 'string') { 74 | continue 75 | } 76 | 77 | const normalizedKey = key.trim() 78 | if (!normalizedKey) { 79 | continue 80 | } 81 | 82 | sanitized[normalizedKey] = currentValue 83 | } 84 | 85 | return Object.keys(sanitized).length > 0 ? sanitized : undefined 86 | } 87 | 88 | /** 89 | * Remove undefined entries and sanitize nested values before hitting native. 90 | */ 91 | export const normalizeOptions = (options?: InAppBrowserOptions) => { 92 | if (!options) { 93 | return undefined 94 | } 95 | 96 | const sanitized: InAppBrowserOptions = {} 97 | 98 | for (const [key, value] of Object.entries(options)) { 99 | if (value === undefined || value === null) { 100 | continue 101 | } 102 | 103 | if (COLOR_OPTION_KEYS.has(key as keyof InAppBrowserOptions)) { 104 | const normalizedColor = sanitizeColor(value as string | DynamicColor) 105 | if (normalizedColor) { 106 | // @ts-expect-error - dynamic key assignment is safe for optional props 107 | sanitized[key] = normalizedColor 108 | } 109 | continue 110 | } 111 | 112 | if (key === 'headers') { 113 | const normalizedHeaders = sanitizeHeaders(value as Record) 114 | if (normalizedHeaders) { 115 | sanitized.headers = normalizedHeaders 116 | } 117 | continue 118 | } 119 | 120 | if (key === 'animations') { 121 | const normalizedAnimations = sanitizeAnimations(value as BrowserAnimations) 122 | if (normalizedAnimations) { 123 | sanitized.animations = normalizedAnimations 124 | } 125 | continue 126 | } 127 | 128 | // @ts-expect-error - dynamic key assignment is safe for optional props 129 | sanitized[key] = value 130 | } 131 | 132 | return Object.keys(sanitized).length > 0 ? sanitized : undefined 133 | } 134 | -------------------------------------------------------------------------------- /example/ios/InAppBrowserNitroExample/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | 7 | dependencies { 8 | classpath "com.android.tools.build:gradle:8.8.0" 9 | } 10 | } 11 | 12 | def reactNativeArchitectures() { 13 | def value = rootProject.getProperties().get("reactNativeArchitectures") 14 | return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] 15 | } 16 | 17 | def isNewArchitectureEnabled() { 18 | return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" 19 | } 20 | 21 | apply plugin: "com.android.library" 22 | apply plugin: 'org.jetbrains.kotlin.android' 23 | apply from: '../nitrogen/generated/android/InappbrowserNitro+autolinking.gradle' 24 | apply from: "./fix-prefab.gradle" 25 | 26 | if (isNewArchitectureEnabled()) { 27 | apply plugin: "com.facebook.react" 28 | } 29 | 30 | def getExtOrDefault(name) { 31 | return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["InappbrowserNitro_" + name] 32 | } 33 | 34 | def getExtOrIntegerDefault(name) { 35 | return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["InappbrowserNitro_" + name]).toInteger() 36 | } 37 | 38 | android { 39 | namespace "com.inappbrowsernitro" 40 | 41 | ndkVersion getExtOrDefault("ndkVersion") 42 | compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") 43 | 44 | defaultConfig { 45 | minSdkVersion getExtOrIntegerDefault("minSdkVersion") 46 | targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") 47 | buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() 48 | 49 | externalNativeBuild { 50 | cmake { 51 | cppFlags "-frtti -fexceptions -Wall -Wextra -fstack-protector-all" 52 | arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" 53 | abiFilters (*reactNativeArchitectures()) 54 | 55 | buildTypes { 56 | debug { 57 | cppFlags "-O1 -g" 58 | } 59 | release { 60 | cppFlags "-O2" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | externalNativeBuild { 68 | cmake { 69 | path "CMakeLists.txt" 70 | } 71 | } 72 | 73 | packagingOptions { 74 | excludes = [ 75 | "META-INF", 76 | "META-INF/**", 77 | "**/libc++_shared.so", 78 | "**/libfbjni.so", 79 | "**/libjsi.so", 80 | "**/libfolly_json.so", 81 | "**/libfolly_runtime.so", 82 | "**/libglog.so", 83 | "**/libhermes.so", 84 | "**/libhermes-executor-debug.so", 85 | "**/libhermes_executor.so", 86 | "**/libreactnative.so", 87 | "**/libreactnativejni.so", 88 | "**/libturbomodulejsijni.so", 89 | "**/libreact_nativemodule_core.so", 90 | "**/libjscexecutor.so" 91 | ] 92 | } 93 | 94 | buildFeatures { 95 | buildConfig true 96 | prefab true 97 | } 98 | 99 | buildTypes { 100 | release { 101 | minifyEnabled false 102 | } 103 | } 104 | 105 | lintOptions { 106 | disable "GradleCompatible" 107 | } 108 | 109 | compileOptions { 110 | sourceCompatibility JavaVersion.VERSION_1_8 111 | targetCompatibility JavaVersion.VERSION_1_8 112 | } 113 | 114 | sourceSets { 115 | main { 116 | if (isNewArchitectureEnabled()) { 117 | java.srcDirs += [ 118 | // React Codegen files 119 | "${project.buildDir}/generated/source/codegen/java" 120 | ] 121 | } 122 | } 123 | } 124 | } 125 | 126 | repositories { 127 | mavenCentral() 128 | google() 129 | } 130 | 131 | 132 | dependencies { 133 | // For < 0.71, this will be from the local maven repo 134 | // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin 135 | //noinspection GradleDynamicVersion 136 | implementation "com.facebook.react:react-native:+" 137 | 138 | // Add a dependency on NitroModules 139 | implementation project(":react-native-nitro-modules") 140 | 141 | implementation "androidx.browser:browser:1.7.0" 142 | implementation "androidx.core:core-ktx:1.13.1" 143 | } 144 | 145 | if (isNewArchitectureEnabled()) { 146 | react { 147 | jsRootDir = file("../src/") 148 | libraryName = "InappbrowserNitro" 149 | codegenJavaPackageName = "com.inappbrowsernitro" 150 | } 151 | } -------------------------------------------------------------------------------- /android/src/main/java/com/inappbrowsernitro/HybridInappbrowserNitro.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitro 2 | 3 | import android.app.Activity 4 | import android.content.ActivityNotFoundException 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.net.Uri 8 | import androidx.browser.customtabs.CustomTabsIntent 9 | import com.inappbrowsernitro.browser.BrowserFallback 10 | import com.inappbrowsernitro.browser.CustomTabsIntentFactory 11 | import com.inappbrowsernitro.browser.CustomTabsPackageHelper 12 | import com.margelo.nitro.NitroModules 13 | import com.margelo.nitro.core.Promise 14 | import com.margelo.nitro.inappbrowsernitro.BrowserResultType 15 | import com.margelo.nitro.inappbrowsernitro.HybridInappbrowserNitroSpec 16 | import com.margelo.nitro.inappbrowsernitro.InAppBrowserAuthResult 17 | import com.margelo.nitro.inappbrowsernitro.InAppBrowserOptions 18 | import com.margelo.nitro.inappbrowsernitro.InAppBrowserResult 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.withContext 21 | 22 | class HybridInappbrowserNitro : HybridInappbrowserNitroSpec() { 23 | private val reactContext get() = NitroModules.applicationContext 24 | private val applicationContext: Context? 25 | get() = reactContext ?: NitroModules.applicationContext 26 | 27 | override fun isAvailable(): Promise { 28 | val context = applicationContext ?: return Promise.resolved(false) 29 | val customTabsPackage = CustomTabsPackageHelper.resolvePackage(context, null) 30 | if (customTabsPackage != null) { 31 | return Promise.resolved(true) 32 | } 33 | 34 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(SCHEME_CHECK_URL)) 35 | val canHandle = intent.resolveActivity(context.packageManager) != null 36 | return Promise.resolved(canHandle) 37 | } 38 | 39 | override fun open(url: String, options: InAppBrowserOptions?): Promise { 40 | return Promise.async { 41 | openInternal(url, options) 42 | } 43 | } 44 | 45 | override fun openAuth(url: String, redirectUrl: String, options: InAppBrowserOptions?): Promise { 46 | return Promise.async { 47 | val result = openInternal(url, options) 48 | InAppBrowserAuthResult(result.type, result.url, result.message) 49 | } 50 | } 51 | 52 | override fun close(): Promise { 53 | return Promise.resolved(Unit) 54 | } 55 | 56 | override fun closeAuth(): Promise { 57 | return Promise.resolved(Unit) 58 | } 59 | 60 | private suspend fun openInternal(url: String, options: InAppBrowserOptions?): InAppBrowserResult { 61 | val context = applicationContext ?: return dismiss("React context unavailable") 62 | val parsedUri = runCatching { Uri.parse(url) }.getOrNull() 63 | ?: return dismiss("Invalid URL: $url") 64 | 65 | val customTabsPackage = CustomTabsPackageHelper.resolvePackage(context, null) 66 | val launchContext = reactContext?.currentActivity ?: context 67 | 68 | if (customTabsPackage == "com.android.chrome") { 69 | val intent = CustomTabsIntentFactory(context, null).create(options) 70 | intent.intent.setPackage(customTabsPackage) 71 | val launched = launchCustomTab(intent, launchContext, parsedUri) 72 | if (launched) { 73 | return InAppBrowserResult(BrowserResultType.SUCCESS, parsedUri.toString(), null) 74 | } 75 | } 76 | 77 | val fallbackLaunched = launchFallback(launchContext, url) 78 | return if (fallbackLaunched) { 79 | InAppBrowserResult(BrowserResultType.SUCCESS, parsedUri.toString(), null) 80 | } else { 81 | dismiss("No browser available to handle $url") 82 | } 83 | } 84 | 85 | private suspend fun launchCustomTab(intent: CustomTabsIntent, context: Context, uri: Uri): Boolean { 86 | return withContext(Dispatchers.Main) { 87 | try { 88 | if (context !is Activity) { 89 | intent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 90 | } 91 | intent.launchUrl(context, uri) 92 | true 93 | } catch (_: ActivityNotFoundException) { 94 | false 95 | } 96 | } 97 | } 98 | 99 | private fun launchFallback(context: Context, url: String): Boolean { 100 | val fallback = BrowserFallback(context) 101 | if (fallback.openSystemBrowser(url)) return true 102 | if (fallback.openChooser(url)) return true 103 | return fallback.redirectToStore() 104 | } 105 | 106 | private fun dismiss(message: String): InAppBrowserResult { 107 | return InAppBrowserResult(BrowserResultType.DISMISS, null, message) 108 | } 109 | 110 | private companion object { 111 | private const val SCHEME_CHECK_URL = "https://example.com" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /example/android/app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.android.application" 2 | apply plugin: "org.jetbrains.kotlin.android" 3 | apply plugin: "com.facebook.react" 4 | 5 | /** 6 | * This is the configuration block to customize your React Native Android app. 7 | * By default you don't need to apply any configuration, just uncomment the lines you need. 8 | */ 9 | react { 10 | /* Folders */ 11 | // The root of your project, i.e. where "package.json" lives. Default is '../..' 12 | // root = file("../../") 13 | // The folder where the react-native NPM package is. Default is ../../node_modules/react-native 14 | reactNativeDir = file("../../../node_modules/react-native") 15 | // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen 16 | codegenDir = file("../../../node_modules/@react-native/codegen") 17 | // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js 18 | cliFile = file("../../../node_modules/react-native/cli.js") 19 | 20 | /* Variants */ 21 | // The list of variants to that are debuggable. For those we're going to 22 | // skip the bundling of the JS bundle and the assets. By default is just 'debug'. 23 | // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. 24 | // debuggableVariants = ["liteDebug", "prodDebug"] 25 | 26 | /* Bundling */ 27 | // A list containing the node command and its flags. Default is just 'node'. 28 | // nodeExecutableAndArgs = ["node"] 29 | // 30 | // The command to run when bundling. By default is 'bundle' 31 | // bundleCommand = "ram-bundle" 32 | // 33 | // The path to the CLI configuration file. Default is empty. 34 | // bundleConfig = file(../rn-cli.config.js) 35 | // 36 | // The name of the generated asset file containing your JS bundle 37 | // bundleAssetName = "MyApplication.android.bundle" 38 | // 39 | // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' 40 | // entryFile = file("../js/MyApplication.android.js") 41 | // 42 | // A list of extra flags to pass to the 'bundle' commands. 43 | // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle 44 | // extraPackagerArgs = [] 45 | 46 | /* Hermes Commands */ 47 | // The hermes compiler command to run. By default it is 'hermesc' 48 | hermesCommand = "$rootDir/../../node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc" 49 | // 50 | // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" 51 | // hermesFlags = ["-O", "-output-source-map"] 52 | 53 | /* Autolinking */ 54 | autolinkLibrariesWithApp() 55 | } 56 | 57 | /** 58 | * Set this to true to Run Proguard on Release builds to minify the Java bytecode. 59 | */ 60 | def enableProguardInReleaseBuilds = false 61 | 62 | /** 63 | * The preferred build flavor of JavaScriptCore (JSC) 64 | * 65 | * For example, to use the international variant, you can use: 66 | * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` 67 | * 68 | * The international variant includes ICU i18n library and necessary data 69 | * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that 70 | * give correct results when using with locales other than en-US. Note that 71 | * this variant is about 6MiB larger per architecture than default. 72 | */ 73 | def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' 74 | 75 | android { 76 | ndkVersion rootProject.ext.ndkVersion 77 | buildToolsVersion rootProject.ext.buildToolsVersion 78 | compileSdk rootProject.ext.compileSdkVersion 79 | 80 | namespace "com.inappbrowsernitroexample" 81 | defaultConfig { 82 | applicationId "com.inappbrowsernitroexample" 83 | minSdkVersion rootProject.ext.minSdkVersion 84 | targetSdkVersion rootProject.ext.targetSdkVersion 85 | versionCode 1 86 | versionName "1.0" 87 | } 88 | signingConfigs { 89 | debug { 90 | storeFile file('debug.keystore') 91 | storePassword 'android' 92 | keyAlias 'androiddebugkey' 93 | keyPassword 'android' 94 | } 95 | } 96 | buildTypes { 97 | debug { 98 | signingConfig signingConfigs.debug 99 | } 100 | release { 101 | // Caution! In production, you need to generate your own keystore file. 102 | // see https://reactnative.dev/docs/signed-apk-android. 103 | signingConfig signingConfigs.debug 104 | minifyEnabled enableProguardInReleaseBuilds 105 | proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" 106 | } 107 | } 108 | } 109 | 110 | dependencies { 111 | // The version of react-native is set by the React Native Gradle Plugin 112 | implementation("com.facebook.react:react-android") 113 | 114 | if (hermesEnabled.toBoolean()) { 115 | implementation("com.facebook.react:hermes-android") 116 | } else { 117 | implementation jscFlavor 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/specs/inappbrowser-nitro.nitro.ts: -------------------------------------------------------------------------------- 1 | import { type HybridObject } from 'react-native-nitro-modules' 2 | 3 | /** 4 | * Discrete result types returned by the native browser implementations. 5 | */ 6 | export const BrowserResultType = { 7 | /** User actively dismissed the browser (tap on Done/Close/back). */ 8 | Cancel: 'cancel', 9 | /** Browser closed due to an error or system level interruption. */ 10 | Dismiss: 'dismiss', 11 | /** Browser launched successfully. */ 12 | Success: 'success', 13 | } as const 14 | 15 | export type BrowserResultType = 16 | (typeof BrowserResultType)[keyof typeof BrowserResultType] 17 | 18 | /** 19 | * iOS dismiss button appearance options. 20 | */ 21 | export const DismissButtonStyle = { 22 | Done: 'done', 23 | Close: 'close', 24 | Cancel: 'cancel', 25 | } as const 26 | 27 | export type DismissButtonStyle = 28 | (typeof DismissButtonStyle)[keyof typeof DismissButtonStyle] 29 | 30 | /** 31 | * iOS presentation styles exposed by Safari Services. 32 | */ 33 | export const ModalPresentationStyle = { 34 | Automatic: 'automatic', 35 | None: 'none', 36 | FullScreen: 'fullScreen', 37 | PageSheet: 'pageSheet', 38 | FormSheet: 'formSheet', 39 | CurrentContext: 'currentContext', 40 | Custom: 'custom', 41 | OverFullScreen: 'overFullScreen', 42 | OverCurrentContext: 'overCurrentContext', 43 | Popover: 'popover', 44 | } as const 45 | 46 | export type ModalPresentationStyle = 47 | (typeof ModalPresentationStyle)[keyof typeof ModalPresentationStyle] 48 | 49 | /** 50 | * iOS transition styles available when presenting Safari. 51 | */ 52 | export const ModalTransitionStyle = { 53 | CoverVertical: 'coverVertical', 54 | FlipHorizontal: 'flipHorizontal', 55 | CrossDissolve: 'crossDissolve', 56 | PartialCurl: 'partialCurl', 57 | } as const 58 | 59 | export type ModalTransitionStyle = 60 | (typeof ModalTransitionStyle)[keyof typeof ModalTransitionStyle] 61 | 62 | /** 63 | * Android Custom Tabs color scheme modes. 64 | */ 65 | export const BrowserColorScheme = { 66 | System: 'system', 67 | Light: 'light', 68 | Dark: 'dark', 69 | } as const 70 | 71 | export type BrowserColorScheme = 72 | (typeof BrowserColorScheme)[keyof typeof BrowserColorScheme] 73 | 74 | /** 75 | * Android Custom Tabs share state visibility. 76 | */ 77 | export const BrowserShareState = { 78 | Default: 'default', 79 | On: 'on', 80 | Off: 'off', 81 | } as const 82 | 83 | export type BrowserShareState = 84 | (typeof BrowserShareState)[keyof typeof BrowserShareState] 85 | 86 | export const StatusBarStyle = { 87 | Default: 'default', 88 | LightContent: 'lightContent', 89 | DarkContent: 'darkContent', 90 | } as const 91 | 92 | export type StatusBarStyle = 93 | (typeof StatusBarStyle)[keyof typeof StatusBarStyle] 94 | 95 | export const UserInterfaceStyle = { 96 | Unspecified: 'unspecified', 97 | Light: 'light', 98 | Dark: 'dark', 99 | } as const 100 | 101 | export type UserInterfaceStyle = 102 | (typeof UserInterfaceStyle)[keyof typeof UserInterfaceStyle] 103 | 104 | /** 105 | * Compact description of a color palette for light/dark/high-contrast modes. 106 | * When provided, native layers pick the most appropriate value per platform. 107 | */ 108 | export interface DynamicColor { 109 | /** Primary color used regardless of theme (fallback). */ 110 | base?: string 111 | /** Primary color used for light interfaces. */ 112 | light?: string 113 | /** Primary color used for dark interfaces. */ 114 | dark?: string 115 | /** High contrast override applied when available (iOS 26+, Android 16+). */ 116 | highContrast?: string 117 | } 118 | 119 | /** 120 | * Reader mode result sizing used when presenting as a form sheet. 121 | */ 122 | export interface FormSheetContentSize { 123 | width: number 124 | height: number 125 | } 126 | 127 | /** 128 | * iOS specific presentation and styling options. 129 | */ 130 | export interface InAppBrowserIOSOptions { 131 | dismissButtonStyle?: DismissButtonStyle 132 | preferredBarTintColor?: DynamicColor 133 | preferredControlTintColor?: DynamicColor 134 | /** 135 | * Tint color applied to the status bar buttons when supported (iOS 15+). 136 | */ 137 | preferredStatusBarStyle?: StatusBarStyle 138 | readerMode?: boolean 139 | animated?: boolean 140 | modalPresentationStyle?: ModalPresentationStyle 141 | modalTransitionStyle?: ModalTransitionStyle 142 | modalEnabled?: boolean 143 | enableBarCollapsing?: boolean 144 | ephemeralWebSession?: boolean 145 | enableEdgeDismiss?: boolean 146 | overrideUserInterfaceStyle?: UserInterfaceStyle 147 | formSheetPreferredContentSize?: FormSheetContentSize 148 | } 149 | 150 | /** 151 | * Android specific presentation and styling options. 152 | */ 153 | export interface InAppBrowserAndroidOptions { 154 | showTitle?: boolean 155 | toolbarColor?: DynamicColor 156 | secondaryToolbarColor?: DynamicColor 157 | navigationBarColor?: DynamicColor 158 | navigationBarDividerColor?: DynamicColor 159 | enableUrlBarHiding?: boolean 160 | enableDefaultShare?: boolean 161 | shareState?: BrowserShareState 162 | colorScheme?: BrowserColorScheme 163 | headers?: Record 164 | forceCloseOnRedirection?: boolean 165 | hasBackButton?: boolean 166 | browserPackage?: string 167 | showInRecents?: boolean 168 | includeReferrer?: boolean 169 | instantAppsEnabled?: boolean 170 | enablePullToRefresh?: boolean 171 | enablePartialCustomTab?: boolean 172 | animations?: BrowserAnimations 173 | } 174 | 175 | /** 176 | * Declarative animation configuration for Android Custom Tabs. 177 | */ 178 | export interface BrowserAnimations { 179 | startEnter?: string 180 | startExit?: string 181 | endEnter?: string 182 | endExit?: string 183 | } 184 | 185 | /** 186 | * Aggregated cross-platform options. 187 | */ 188 | export interface InAppBrowserOptions 189 | extends InAppBrowserIOSOptions, 190 | InAppBrowserAndroidOptions { 191 | headers?: Record 192 | } 193 | 194 | /** 195 | * Result payload returned by imperative API calls. 196 | */ 197 | export interface InAppBrowserResult { 198 | type: BrowserResultType 199 | url?: string 200 | message?: string 201 | } 202 | 203 | /** 204 | * Authentication result payload (mirrors regular result semantics). 205 | */ 206 | export interface InAppBrowserAuthResult extends InAppBrowserResult {} 207 | 208 | export interface InappbrowserNitro 209 | extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { 210 | /** 211 | * Report whether the Native runtime can present an in-app browser. 212 | */ 213 | isAvailable(): Promise 214 | 215 | /** 216 | * Present an in-app browser with the supplied configuration. 217 | */ 218 | open(url: string, options?: InAppBrowserOptions): Promise 219 | 220 | /** 221 | * Launch an authentication flow and resolve with the redirect payload. 222 | */ 223 | openAuth( 224 | url: string, 225 | redirectUrl: string, 226 | options?: InAppBrowserOptions 227 | ): Promise 228 | 229 | /** 230 | * Close any visible browser session. 231 | */ 232 | close(): Promise 233 | 234 | /** 235 | * Dismiss an ongoing authentication session. 236 | */ 237 | closeAuth(): Promise 238 | 239 | } -------------------------------------------------------------------------------- /ios/SafariPresenter.swift: -------------------------------------------------------------------------------- 1 | import SafariServices 2 | import UIKit 3 | 4 | final class SafariPresenter: NSObject { 5 | private var controller: NitroSafariViewController? 6 | private var delegate: SafariDismissDelegate? 7 | 8 | @MainActor 9 | func present(urlString: String, options: InAppBrowserOptions?) -> InAppBrowserResult { 10 | guard let url = URL(string: urlString) else { 11 | return InAppBrowserResult(type: .dismiss, url: nil, message: "invalid url") 12 | } 13 | 14 | let configuration = SFSafariViewController.Configuration() 15 | configuration.entersReaderIfAvailable = options?.readerMode ?? false 16 | configuration.barCollapsingEnabled = options?.enableBarCollapsing ?? false 17 | 18 | let controller = NitroSafariViewController( 19 | url: url, 20 | configuration: configuration, 21 | statusBarStyle: SafariStyleMapper.statusBarStyle(from: options?.preferredStatusBarStyle), 22 | userInterfaceStyle: SafariStyleMapper.interfaceStyle(from: options?.overrideUserInterfaceStyle) 23 | ) 24 | 25 | apply(options: options, to: controller) 26 | 27 | let delegate = SafariDismissDelegate { [weak self] in 28 | self?.controller = nil 29 | self?.delegate = nil 30 | } 31 | 32 | controller.delegate = delegate 33 | controller.isModalInPresentation = !(options?.enableEdgeDismiss ?? true) 34 | 35 | guard let presenter = UIApplication.shared.nitroTopMostViewController else { 36 | return InAppBrowserResult(type: .dismiss, url: nil, message: "missing presenter") 37 | } 38 | 39 | self.controller = controller 40 | self.delegate = delegate 41 | 42 | let animated = options?.animated ?? true 43 | 44 | if options?.modalEnabled == false, 45 | let navigation = (presenter as? UINavigationController) ?? presenter.navigationController { 46 | navigation.pushViewController(controller, animated: animated) 47 | } else { 48 | presenter.present(controller, animated: animated) 49 | } 50 | 51 | return InAppBrowserResult(type: .success, url: url.absoluteString, message: nil) 52 | } 53 | 54 | @MainActor 55 | func dismiss() async { 56 | guard let controller else { return } 57 | 58 | if let navigation = controller.navigationController, 59 | navigation.viewControllers.contains(where: { $0 === controller }) { 60 | if navigation.topViewController === controller { 61 | navigation.popViewController(animated: true) 62 | } else { 63 | navigation.viewControllers.removeAll { $0 === controller } 64 | } 65 | } else if controller.presentingViewController != nil { 66 | await withCheckedContinuation { continuation in 67 | controller.dismiss(animated: true) { 68 | continuation.resume(returning: ()) 69 | } 70 | } 71 | } 72 | 73 | self.controller = nil 74 | self.delegate = nil 75 | } 76 | 77 | private func apply(options: InAppBrowserOptions?, to controller: SFSafariViewController) { 78 | if let barColor = UIColor.from(dynamicColor: options?.preferredBarTintColor) { 79 | controller.preferredBarTintColor = barColor 80 | } 81 | 82 | if let controlColor = UIColor.from(dynamicColor: options?.preferredControlTintColor) { 83 | controller.preferredControlTintColor = controlColor 84 | } 85 | 86 | if let dismissStyle = options?.dismissButtonStyle { 87 | controller.dismissButtonStyle = SafariStyleMapper.dismissButtonStyle(from: dismissStyle) 88 | } 89 | 90 | if let formSize = options?.formSheetPreferredContentSize { 91 | controller.preferredContentSize = CGSize(width: CGFloat(formSize.width), height: CGFloat(formSize.height)) 92 | } 93 | 94 | if let presentation = options?.modalPresentationStyle { 95 | controller.modalPresentationStyle = SafariStyleMapper.presentationStyle(from: presentation) 96 | } 97 | 98 | if let transition = options?.modalTransitionStyle { 99 | controller.modalTransitionStyle = SafariStyleMapper.transitionStyle(from: transition) 100 | if transition == .partialcurl { 101 | controller.modalPresentationStyle = .fullScreen 102 | } 103 | } 104 | } 105 | } 106 | 107 | private final class NitroSafariViewController: SFSafariViewController { 108 | private let resolvedStatusBarStyle: UIStatusBarStyle? 109 | 110 | init(url: URL, configuration: SFSafariViewController.Configuration, statusBarStyle: UIStatusBarStyle?, userInterfaceStyle: UIUserInterfaceStyle?) { 111 | resolvedStatusBarStyle = statusBarStyle 112 | super.init(url: url, configuration: configuration) 113 | 114 | if let userInterfaceStyle, #available(iOS 13.0, *) { 115 | overrideUserInterfaceStyle = userInterfaceStyle 116 | } 117 | } 118 | 119 | override var preferredStatusBarStyle: UIStatusBarStyle { 120 | resolvedStatusBarStyle ?? super.preferredStatusBarStyle 121 | } 122 | } 123 | 124 | private final class SafariDismissDelegate: NSObject, SFSafariViewControllerDelegate { 125 | private let onDismiss: () -> Void 126 | 127 | init(onDismiss: @escaping () -> Void) { 128 | self.onDismiss = onDismiss 129 | } 130 | 131 | func safariViewControllerDidFinish(_ controller: SFSafariViewController) { 132 | // iOS Simulator (and hardware without biometric setup) dismisses immediately for some auth flows. 133 | onDismiss() 134 | } 135 | } 136 | 137 | private enum SafariStyleMapper { 138 | static func dismissButtonStyle(from style: DismissButtonStyle) -> SFSafariViewController.DismissButtonStyle { 139 | switch style { 140 | case .cancel: 141 | return .cancel 142 | case .done: 143 | return .done 144 | case .close: 145 | return .close 146 | @unknown default: 147 | return .done 148 | } 149 | } 150 | 151 | static func presentationStyle(from style: ModalPresentationStyle) -> UIModalPresentationStyle { 152 | switch style { 153 | case .automatic: 154 | if #available(iOS 13.0, *) { 155 | return .automatic 156 | } 157 | return .fullScreen 158 | case .none: 159 | return .none 160 | case .fullscreen: 161 | return .fullScreen 162 | case .pagesheet: 163 | if #available(iOS 13.0, *) { 164 | return .pageSheet 165 | } 166 | return .formSheet 167 | case .formsheet: 168 | return .formSheet 169 | case .currentcontext: 170 | return .currentContext 171 | case .custom: 172 | return .custom 173 | case .overfullscreen: 174 | return .overFullScreen 175 | case .overcurrentcontext: 176 | return .overCurrentContext 177 | case .popover: 178 | return .popover 179 | @unknown default: 180 | return .automatic 181 | } 182 | } 183 | 184 | static func transitionStyle(from style: ModalTransitionStyle) -> UIModalTransitionStyle { 185 | switch style { 186 | case .coververtical: 187 | return .coverVertical 188 | case .fliphorizontal: 189 | return .flipHorizontal 190 | case .crossdissolve: 191 | return .crossDissolve 192 | case .partialcurl: 193 | return .partialCurl 194 | @unknown default: 195 | return .coverVertical 196 | } 197 | } 198 | 199 | static func statusBarStyle(from style: StatusBarStyle?) -> UIStatusBarStyle? { 200 | guard let style else { return nil } 201 | switch style { 202 | case .default: 203 | return .default 204 | case .lightcontent: 205 | return .lightContent 206 | case .darkcontent: 207 | if #available(iOS 13.0, *) { 208 | return .darkContent 209 | } 210 | return .default 211 | @unknown default: 212 | return nil 213 | } 214 | } 215 | 216 | static func interfaceStyle(from style: UserInterfaceStyle?) -> UIUserInterfaceStyle? { 217 | guard let style, #available(iOS 13.0, *) else { return nil } 218 | switch style { 219 | case .unspecified: 220 | return .unspecified 221 | case .light: 222 | return .light 223 | case .dark: 224 | return .dark 225 | @unknown default: 226 | return nil 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /example/android/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /android/src/main/java/com/inappbrowsernitro/browser/CustomTabsIntentFactory.kt: -------------------------------------------------------------------------------- 1 | package com.inappbrowsernitro.browser 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.graphics.Bitmap 6 | import android.graphics.Canvas 7 | import android.graphics.Color 8 | import android.graphics.Paint 9 | import android.graphics.Path 10 | import android.net.Uri 11 | import android.os.Build 12 | import android.os.Bundle 13 | import androidx.browser.customtabs.CustomTabColorSchemeParams 14 | import androidx.browser.customtabs.CustomTabsIntent 15 | import androidx.browser.customtabs.CustomTabsSession 16 | import com.margelo.nitro.inappbrowsernitro.BrowserAnimations 17 | import com.margelo.nitro.inappbrowsernitro.BrowserColorScheme 18 | import com.margelo.nitro.inappbrowsernitro.BrowserShareState 19 | import com.margelo.nitro.inappbrowsernitro.InAppBrowserOptions 20 | 21 | internal class CustomTabsIntentFactory( 22 | private val context: Context, 23 | private val session: CustomTabsSession? 24 | ) { 25 | fun create(options: InAppBrowserOptions?): CustomTabsIntent { 26 | val builder = session?.let { CustomTabsIntent.Builder(it) } ?: CustomTabsIntent.Builder() 27 | 28 | applyColors(builder, options) 29 | applyBehaviours(builder, options) 30 | applyNavigation(builder, options) 31 | applyAnimations(builder, options?.animations) 32 | 33 | val intent = builder.build() 34 | 35 | configureIntent(intent.intent, options) 36 | 37 | options?.browserPackage?.takeIf { it.isNotBlank() }?.let(intent.intent::setPackage) 38 | 39 | return intent 40 | } 41 | 42 | private fun applyColors(builder: CustomTabsIntent.Builder, options: InAppBrowserOptions?) { 43 | val colorSchemes = buildColorParams(options) 44 | 45 | when { 46 | colorSchemes?.system != null -> builder.setDefaultColorSchemeParams(colorSchemes.system) 47 | colorSchemes?.light != null -> builder.setDefaultColorSchemeParams(colorSchemes.light) 48 | colorSchemes?.dark != null -> builder.setDefaultColorSchemeParams(colorSchemes.dark) 49 | } 50 | 51 | colorSchemes?.light?.let { 52 | builder.setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_LIGHT, it) 53 | } 54 | 55 | colorSchemes?.dark?.let { 56 | builder.setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, it) 57 | } 58 | 59 | options?.colorScheme?.let { scheme -> 60 | builder.setColorScheme(scheme.toCustomTabsScheme()) 61 | } 62 | } 63 | 64 | private fun applyBehaviours(builder: CustomTabsIntent.Builder, options: InAppBrowserOptions?) { 65 | builder.setShowTitle(options?.showTitle ?: true) 66 | 67 | options?.enableUrlBarHiding?.let(builder::setUrlBarHidingEnabled) 68 | 69 | when (options?.shareState) { 70 | BrowserShareState.ON -> builder.setShareState(CustomTabsIntent.SHARE_STATE_ON) 71 | BrowserShareState.OFF -> builder.setShareState(CustomTabsIntent.SHARE_STATE_OFF) 72 | BrowserShareState.DEFAULT, null -> if (options?.enableDefaultShare == false) { 73 | builder.setShareState(CustomTabsIntent.SHARE_STATE_OFF) 74 | } 75 | } 76 | 77 | options?.instantAppsEnabled?.let(builder::setInstantAppsEnabled) 78 | 79 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && options?.enablePartialCustomTab == true) { 80 | val height = (context.resources.displayMetrics.heightPixels * PARTIAL_TAB_RATIO).toInt() 81 | builder.setInitialActivityHeightPx(height, CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE) 82 | } 83 | 84 | // Emulators rely on soft navigation buttons; keeping pull-to-refresh disabled avoids accidental reloads. 85 | if (options?.enablePullToRefresh == true) { 86 | builder.setUrlBarHidingEnabled(true) 87 | } 88 | } 89 | 90 | private fun applyNavigation(builder: CustomTabsIntent.Builder, options: InAppBrowserOptions?) { 91 | if (options?.hasBackButton == true) { 92 | builder.setCloseButtonIcon(createBackArrow()) 93 | } 94 | } 95 | 96 | private fun applyAnimations(builder: CustomTabsIntent.Builder, animations: BrowserAnimations?) { 97 | animations ?: return 98 | val startEnter = resolveAnimation(animations.startEnter) 99 | val startExit = resolveAnimation(animations.startExit) 100 | if (startEnter != null && startExit != null) { 101 | builder.setStartAnimations(context, startEnter, startExit) 102 | } 103 | 104 | val endEnter = resolveAnimation(animations.endEnter) 105 | val endExit = resolveAnimation(animations.endExit) 106 | if (endEnter != null && endExit != null) { 107 | builder.setExitAnimations(context, endEnter, endExit) 108 | } 109 | } 110 | 111 | private fun configureIntent(intent: Intent, options: InAppBrowserOptions?) { 112 | if (options?.showInRecents == false) { 113 | intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) 114 | } 115 | 116 | if (options?.includeReferrer == true) { 117 | val referrer = Uri.parse("android-app://${context.packageName}") 118 | intent.putExtra(Intent.EXTRA_REFERRER, referrer) 119 | } 120 | 121 | options?.headers?.takeIf { it.isNotEmpty() }?.let { headers -> 122 | val bundle = Bundle() 123 | headers.forEach { (key, value) -> 124 | bundle.putString(key, value) 125 | } 126 | intent.putExtra(BROWSER_EXTRA_HEADERS, bundle) 127 | } 128 | } 129 | 130 | private fun buildColorParams(options: InAppBrowserOptions?): ColorSchemeParams? { 131 | val system = resolveColorSet(options, DynamicColorResolver.DynamicScheme.SYSTEM) 132 | val light = resolveColorSet(options, DynamicColorResolver.DynamicScheme.LIGHT) 133 | val dark = resolveColorSet(options, DynamicColorResolver.DynamicScheme.DARK) 134 | 135 | val systemParams = system.toCustomTabParams() 136 | val lightParams = light.toCustomTabParams() 137 | val darkParams = dark.toCustomTabParams() 138 | 139 | if (systemParams == null && lightParams == null && darkParams == null) { 140 | return null 141 | } 142 | 143 | return ColorSchemeParams( 144 | system = systemParams, 145 | light = lightParams, 146 | dark = darkParams, 147 | ) 148 | } 149 | 150 | private fun resolveColorSet(options: InAppBrowserOptions?, scheme: DynamicColorResolver.DynamicScheme): ColorSet { 151 | val toolbar = DynamicColorResolver.resolveForScheme(options?.toolbarColor, scheme) 152 | val secondaryToolbar = DynamicColorResolver.resolveForScheme(options?.secondaryToolbarColor, scheme) 153 | val navigationBar = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { 154 | DynamicColorResolver.resolveForScheme(options?.navigationBarColor, scheme) 155 | } else { 156 | null 157 | } 158 | val navigationDivider = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 159 | DynamicColorResolver.resolveForScheme(options?.navigationBarDividerColor, scheme) 160 | } else { 161 | null 162 | } 163 | 164 | return ColorSet( 165 | toolbar = toolbar, 166 | secondaryToolbar = secondaryToolbar, 167 | navigationBar = navigationBar, 168 | navigationBarDivider = navigationDivider, 169 | ) 170 | } 171 | 172 | private fun resolveAnimation(name: String?): Int? { 173 | if (name.isNullOrBlank()) { 174 | return null 175 | } 176 | 177 | val identifier = context.resources.getIdentifier(name, "anim", context.packageName) 178 | return identifier.takeIf { it != 0 } 179 | } 180 | 181 | private fun createBackArrow(): Bitmap { 182 | val size = context.resources.displayMetrics.density * 24 183 | val bitmap = Bitmap.createBitmap(size.toInt(), size.toInt(), Bitmap.Config.ARGB_8888) 184 | val canvas = Canvas(bitmap) 185 | 186 | val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 187 | color = DEFAULT_CLOSE_BUTTON_COLOR 188 | style = Paint.Style.STROKE 189 | strokeWidth = context.resources.displayMetrics.density * 2 190 | strokeCap = Paint.Cap.ROUND 191 | strokeJoin = Paint.Join.ROUND 192 | } 193 | 194 | val path = Path().apply { 195 | moveTo(size * 0.75f, size * 0.2f) 196 | lineTo(size * 0.35f, size * 0.5f) 197 | lineTo(size * 0.75f, size * 0.8f) 198 | } 199 | 200 | canvas.drawPath(path, paint) 201 | return bitmap 202 | } 203 | 204 | private data class ColorSchemeParams( 205 | val system: CustomTabColorSchemeParams?, 206 | val light: CustomTabColorSchemeParams?, 207 | val dark: CustomTabColorSchemeParams?, 208 | ) 209 | 210 | private data class ColorSet( 211 | val toolbar: Int?, 212 | val secondaryToolbar: Int?, 213 | val navigationBar: Int?, 214 | val navigationBarDivider: Int?, 215 | ) { 216 | fun hasAny(): Boolean { 217 | return toolbar != null || secondaryToolbar != null || navigationBar != null || navigationBarDivider != null 218 | } 219 | 220 | fun toCustomTabParams(): CustomTabColorSchemeParams? { 221 | if (!hasAny()) { 222 | return null 223 | } 224 | 225 | return CustomTabColorSchemeParams.Builder().apply { 226 | toolbar?.let(::setToolbarColor) 227 | secondaryToolbar?.let(::setSecondaryToolbarColor) 228 | navigationBar?.let(::setNavigationBarColor) 229 | navigationBarDivider?.let(::setNavigationBarDividerColor) 230 | }.build() 231 | } 232 | } 233 | 234 | private fun BrowserColorScheme.toCustomTabsScheme(): Int { 235 | return when (this) { 236 | BrowserColorScheme.LIGHT -> CustomTabsIntent.COLOR_SCHEME_LIGHT 237 | BrowserColorScheme.DARK -> CustomTabsIntent.COLOR_SCHEME_DARK 238 | BrowserColorScheme.SYSTEM -> CustomTabsIntent.COLOR_SCHEME_SYSTEM 239 | } 240 | } 241 | 242 | private companion object { 243 | private const val PARTIAL_TAB_RATIO = 0.85f 244 | private const val BROWSER_EXTRA_HEADERS = "android.support.customtabs.extra.EXTRA_HEADERS" 245 | private val DEFAULT_CLOSE_BUTTON_COLOR = Color.argb(0xFF, 0x3A, 0x3A, 0x3A) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /example/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from 'react' 2 | import { 3 | Platform, 4 | SafeAreaView, 5 | ScrollView, 6 | StyleSheet, 7 | Text, 8 | TouchableOpacity, 9 | View, 10 | } from 'react-native' 11 | import { 12 | InAppBrowser, 13 | useInAppBrowser, 14 | BrowserShareState, 15 | } from 'react-native-inappbrowser-nitro' 16 | 17 | import type { InAppBrowserResult } from 'react-native-inappbrowser-nitro' 18 | 19 | const REPO_URL = 'https://github.com/mCodex/react-native-inappbrowser-nitro' 20 | const AUTH_REDIRECT_URL = 'inappbrowsernitro://callback' 21 | const AUTH_URL = `https://httpbin.org/redirect-to?url=${encodeURIComponent(AUTH_REDIRECT_URL)}` 22 | 23 | const toolbarPalette = { 24 | base: '#2563EB', 25 | dark: '#1E3A8A', 26 | highContrast: '#1D4ED8', 27 | } 28 | 29 | const controlPalette = { 30 | base: '#FFFFFF', 31 | highContrast: '#FFD700', 32 | } 33 | 34 | const ios26ShowcasePalette = { 35 | base: '#F97316', 36 | light: '#FDBA74', 37 | dark: '#7C3AED', 38 | highContrast: '#DC2626', 39 | } 40 | 41 | const ios26ControlPalette = { 42 | base: '#0F172A', 43 | light: '#1E293B', 44 | dark: '#F8FAFC', 45 | highContrast: '#0EA5E9', 46 | } 47 | 48 | type ExampleButtonProps = { 49 | label: string 50 | onPress: () => Promise | void 51 | disabled?: boolean 52 | tone?: 'primary' | 'secondary' 53 | } 54 | 55 | const ExampleButton = ({ label, onPress, disabled, tone = 'primary' }: ExampleButtonProps) => { 56 | const backgroundStyle = useMemo(() => { 57 | if (disabled) { 58 | return styles.buttonDisabled 59 | } 60 | return tone === 'primary' ? styles.buttonPrimary : styles.buttonSecondary 61 | }, [disabled, tone]) 62 | 63 | return ( 64 | 65 | {label} 66 | 67 | ) 68 | } 69 | 70 | const formatResult = (result: InAppBrowserResult) => { 71 | const parts = [`type: ${result.type}`] 72 | if (result.url) { 73 | parts.push(`url: ${result.url}`) 74 | } 75 | if (result.message) { 76 | parts.push(`message: ${result.message}`) 77 | } 78 | return parts.join(' • ') 79 | } 80 | 81 | const formatError = (err: unknown) => { 82 | if (err instanceof Error) { 83 | return `error: ${err.message}` 84 | } 85 | return `error: ${String(err)}` 86 | } 87 | 88 | function App(): React.JSX.Element { 89 | const { open, openAuth, close, isLoading, error } = useInAppBrowser() 90 | const [isSupported, setIsSupported] = useState(null) 91 | const [log, setLog] = useState([]) 92 | 93 | useEffect(() => { 94 | InAppBrowser.isAvailable() 95 | .then(setIsSupported) 96 | .catch(() => setIsSupported(false)) 97 | }, []) 98 | 99 | const pushLog = useCallback((message: string) => { 100 | setLog(current => [message, ...current].slice(0, 6)) 101 | }, []) 102 | 103 | const handleOpenDocs = useCallback(async () => { 104 | try { 105 | const result = await open(REPO_URL, { 106 | preferredBarTintColor: toolbarPalette, 107 | preferredControlTintColor: controlPalette, 108 | preferredStatusBarStyle: 'lightContent', 109 | overrideUserInterfaceStyle: 'dark', 110 | toolbarColor: toolbarPalette, 111 | secondaryToolbarColor: { base: '#111827' }, 112 | navigationBarColor: { base: '#111827' }, 113 | enablePartialCustomTab: true, 114 | enablePullToRefresh: true, 115 | includeReferrer: true, 116 | shareState: BrowserShareState.Off, 117 | }) 118 | 119 | pushLog(formatResult(result)) 120 | } catch (err) { 121 | pushLog(formatError(err)) 122 | } 123 | }, [open, pushLog]) 124 | 125 | const handleOpenReader = useCallback(async () => { 126 | try { 127 | const result = await open(REPO_URL, { 128 | readerMode: true, 129 | enableBarCollapsing: true, 130 | dismissButtonStyle: 'close', 131 | preferredBarTintColor: { base: '#FFFFFF', dark: '#111827' }, 132 | preferredControlTintColor: controlPalette, 133 | enableEdgeDismiss: true, 134 | }) 135 | 136 | pushLog(formatResult(result)) 137 | } catch (err) { 138 | pushLog(formatError(err)) 139 | } 140 | }, [open, pushLog]) 141 | 142 | const handleOpenIos26Palette = useCallback(async () => { 143 | try { 144 | const result = await open(REPO_URL, { 145 | preferredBarTintColor: ios26ShowcasePalette, 146 | preferredControlTintColor: ios26ControlPalette, 147 | preferredStatusBarStyle: 'darkContent', 148 | overrideUserInterfaceStyle: 'light', 149 | dismissButtonStyle: 'done', 150 | enableEdgeDismiss: false, 151 | formSheetPreferredContentSize: { width: 414, height: 720 }, 152 | }) 153 | 154 | pushLog(`iOS 26 palette • ${formatResult(result)}`) 155 | } catch (err) { 156 | pushLog(formatError(err)) 157 | } 158 | }, [open, pushLog]) 159 | 160 | const handleAuth = useCallback(async () => { 161 | try { 162 | const result = await openAuth(AUTH_URL, AUTH_REDIRECT_URL, { 163 | ephemeralWebSession: true, 164 | enableEdgeDismiss: false, 165 | preferredBarTintColor: toolbarPalette, 166 | toolbarColor: toolbarPalette, 167 | includeReferrer: true, 168 | }) 169 | 170 | pushLog(formatResult(result)) 171 | } catch (err) { 172 | pushLog(formatError(err)) 173 | } 174 | }, [openAuth, pushLog]) 175 | 176 | const handleClose = useCallback(async () => { 177 | try { 178 | await close() 179 | pushLog('close(): requested dismissal') 180 | } catch (err) { 181 | pushLog(formatError(err)) 182 | } 183 | }, [close, pushLog]) 184 | 185 | const supportCopy = useMemo(() => { 186 | if (isSupported === null) { 187 | return 'Checking native availability…' 188 | } 189 | if (!isSupported) { 190 | return 'Native browser support is unavailable on this device/emulator.' 191 | } 192 | return 'Native browser support detected.' 193 | }, [isSupported]) 194 | 195 | return ( 196 | 197 | 198 | react-native-inappbrowser-nitro 199 | Nitro-powered in-app browser with iOS 26 & Android 16 features. 200 | 201 | 202 | Status 203 | {supportCopy} 204 | Loading: {isLoading ? 'yes' : 'no'} 205 | {error && Last error: {error.message}} 206 | 207 | 208 | 209 | Try the Features 210 | 211 | 217 | 222 | 227 | 228 | 229 | 230 | 231 | Security Notes 232 | 233 | iOS 26 allows blocking swipe-to-dismiss during sensitive auth flows via enableEdgeDismiss. Android 234 | 16 adds dynamic contrast colors and partial custom tabs; emulators without gesture navigation may require the hardware back 235 | button to exit. 236 | 237 | 238 | The showcase button demonstrates the new iOS 26 palette controls, including high-contrast overrides, status bar tinting, and 239 | form-sheet sizing. On older iOS versions the system gracefully ignores unsupported keys. 240 | 241 | 242 | 243 | 244 | Recent Results 245 | {log.length === 0 ? ( 246 | Interact with the buttons above to populate the log. 247 | ) : ( 248 | log.map((entry, index) => ( 249 | 250 | {index + 1}. {entry} 251 | 252 | )) 253 | )} 254 | 255 | 256 | 257 | 258 | Update AUTH_URL in example/App.tsx with your provider's authorize endpoint and 259 | register {AUTH_REDIRECT_URL} in your native projects to test redirect-based flows. 260 | 261 | 262 | 263 | 264 | ) 265 | } 266 | 267 | const styles = StyleSheet.create({ 268 | safeArea: { 269 | flex: 1, 270 | backgroundColor: '#0F172A', 271 | }, 272 | content: { 273 | padding: 24, 274 | gap: 24, 275 | }, 276 | title: { 277 | fontSize: 24, 278 | fontWeight: '700', 279 | color: '#F8FAFC', 280 | }, 281 | subtitle: { 282 | fontSize: 14, 283 | color: '#CBD5F5', 284 | }, 285 | card: { 286 | backgroundColor: '#111827', 287 | borderRadius: 12, 288 | padding: 16, 289 | gap: 12, 290 | }, 291 | sectionTitle: { 292 | fontSize: 16, 293 | fontWeight: '600', 294 | color: '#F8FAFC', 295 | }, 296 | paragraph: { 297 | fontSize: 14, 298 | color: '#E2E8F0', 299 | lineHeight: 20, 300 | }, 301 | errorText: { 302 | color: '#F87171', 303 | }, 304 | button: { 305 | paddingVertical: 12, 306 | borderRadius: 10, 307 | alignItems: 'center', 308 | }, 309 | buttonPrimary: { 310 | backgroundColor: '#2563EB', 311 | }, 312 | buttonSecondary: { 313 | backgroundColor: '#334155', 314 | }, 315 | buttonDisabled: { 316 | backgroundColor: '#1E293B', 317 | opacity: 0.6, 318 | }, 319 | buttonText: { 320 | color: '#F8FAFC', 321 | fontSize: 15, 322 | fontWeight: '600', 323 | }, 324 | logLine: { 325 | fontSize: 13, 326 | color: '#94A3B8', 327 | }, 328 | footer: { 329 | paddingBottom: 32, 330 | }, 331 | footerText: { 332 | fontSize: 13, 333 | color: '#CBD5F5', 334 | lineHeight: 18, 335 | }, 336 | code: { 337 | fontFamily: 'Courier', 338 | color: '#38BDF8', 339 | }, 340 | }) 341 | 342 | export default App -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-inappbrowser-nitro 2 | 3 | [![npm version](https://img.shields.io/npm/v/react-native-inappbrowser-nitro.svg?style=flat-square)](https://www.npmjs.com/package/react-native-inappbrowser-nitro) 4 | [![npm downloads](https://img.shields.io/npm/dm/react-native-inappbrowser-nitro.svg?style=flat-square)](https://www.npmjs.com/package/react-native-inappbrowser-nitro) 5 | [![license](https://img.shields.io/npm/l/react-native-inappbrowser-nitro.svg?style=flat-square)](LICENSE) 6 | 7 | Lightning-fast, modern in-app browser for React Native powered by Nitro Modules. Enjoy direct JSI bindings, zero bridge overhead, and polished native browser UX on both iOS and Android. 8 | 9 |
10 | InAppBrowser Nitro Demo 11 |
12 | 13 | ## Contents 14 | 15 | - [Highlights](#highlights) 16 | - [Platform Support](#platform-support) 17 | - [Installation](#installation) 18 | - [Usage](#usage) 19 | - [Migration Guide (pre-Nitro → latest)](#migration-guide-pre-nitro--latest) 20 | - [API Surface](#api-surface) 21 | - [Options Reference](#options-reference) 22 | - [Dynamic Color Palettes](#dynamic-color-palettes) 23 | - [Security & Emulator Notes](#security--emulator-notes) 24 | - [Troubleshooting](#troubleshooting) 25 | - [Contributing](#contributing) 26 | - [License](#license) 27 | 28 | ## Highlights 29 | 30 | - TurboModule-first implementation with native-speed execution and zero bridge overhead. 31 | - Fully typed TypeScript API plus ergonomic React hook helpers. 32 | - ✅ **iOS 26 ready**: high-contrast dynamic colors, edge-dismiss control, and UI style overrides. 33 | - ✅ **Android 16 ready**: partial custom tabs, referrer controls, and per-theme palettes. 34 | - Authentication-first design with ephemeral sessions and graceful fallbacks. 35 | - Works great with Hermes, Fabric, and the React Native New Architecture. 36 | 37 | ## Platform Support 38 | 39 | | Platform | Minimum | Targeted | Highlights | 40 | |----------|---------|----------|------------| 41 | | iOS | 11.0 | 17 / 26* | SafariViewController + ASWebAuthentication support. | 42 | | Android | API 23 | API 34 / 16* | Chrome Custom Tabs with dynamic theming. | 43 | | React Native | 0.70.0 | Latest | Nitro Modules + TypeScript bindings. | 44 | 45 | \* iOS 26 and Android 16 features are automatically gated behind runtime checks. 46 | 47 | > **Expo** is not supported because Nitro modules require native compilation. 48 | 49 | ## Android Browser Fallback 50 | 51 |
52 | Browser selection logic on Android 53 | 54 | On Android, the library prioritizes Chrome Custom Tabs for the best user experience when Chrome is installed. If Chrome is not available (e.g., on Samsung devices), it falls back to opening the URL in the device's default web browser using the standard `Intent.ACTION_VIEW` mechanism. 55 | 56 | This ensures compatibility across devices while maintaining optimal performance with Chrome when possible. 57 |
58 | 59 | ## Installation 60 | 61 | ```sh 62 | npm install react-native-inappbrowser-nitro react-native-nitro-modules 63 | ``` 64 | 65 | or 66 | 67 | ```sh 68 | yarn add react-native-inappbrowser-nitro react-native-nitro-modules 69 | ``` 70 | 71 | ### iOS 72 | 73 | ```sh 74 | cd ios 75 | pod install 76 | ``` 77 | 78 | ### Android 79 | 80 | No additional steps—Gradle autolinking handles everything. 81 | 82 | ## Usage 83 | 84 | ### Imperative API 85 | 86 | ```tsx 87 | import { InAppBrowser } from 'react-native-inappbrowser-nitro' 88 | 89 | async function openDocs() { 90 | if (!(await InAppBrowser.isAvailable())) { 91 | console.warn('No compatible browser found') 92 | return 93 | } 94 | 95 | const result = await InAppBrowser.open('https://nitro.margelo.com', { 96 | preferredBarTintColor: { base: '#111827', light: '#1F2933', highContrast: '#000000' }, 97 | preferredControlTintColor: { base: '#F9FAFB', highContrast: '#FFD700' }, // iOS 26+ 98 | toolbarColor: { base: '#2563EB', dark: '#1E3A8A' }, 99 | enablePartialCustomTab: true, // Android 16+ 100 | }) 101 | 102 | console.log(result) 103 | } 104 | ``` 105 | 106 | ### React Hook 107 | 108 | ```tsx 109 | import { useInAppBrowser } from 'react-native-inappbrowser-nitro' 110 | 111 | export function LaunchButton() { 112 | const { open, isLoading, error } = useInAppBrowser() 113 | 114 | return ( 115 |