Skip to content

Commit c8703f5

Browse files
committed
Add cross-platform in-app browser Nitro module
Introduces a new in-app browser module with native Android (Custom Tabs) and iOS (Safari Services) implementations, including imperative and React hook APIs. Adds option normalization, URL validation, and dynamic color support. Updates TypeScript specs and exports for a unified cross-platform interface.
1 parent 1dcf094 commit c8703f5

19 files changed

Lines changed: 1567 additions & 24 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.inappbrowsernitro.browser
2+
3+
import android.app.Activity
4+
import android.content.ActivityNotFoundException
5+
import android.content.Intent
6+
import android.net.Uri
7+
import androidx.core.net.toUri
8+
9+
internal class BrowserFallback(private val activity: Activity) {
10+
fun openSystemBrowser(url: String): Boolean {
11+
val uri = url.toUri()
12+
13+
return try {
14+
val fallbackIntent = Intent(Intent.ACTION_VIEW, uri)
15+
activity.startActivity(fallbackIntent)
16+
true
17+
} catch (_: ActivityNotFoundException) {
18+
false
19+
}
20+
}
21+
22+
fun openChooser(url: String): Boolean {
23+
val uri = url.toUri()
24+
val intent = Intent(Intent.ACTION_VIEW, uri)
25+
val chooser = Intent.createChooser(intent, "Choose browser")
26+
27+
return try {
28+
activity.startActivity(chooser)
29+
true
30+
} catch (_: ActivityNotFoundException) {
31+
false
32+
}
33+
}
34+
35+
fun redirectToStore(): Boolean {
36+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://search?q=browser"))
37+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
38+
39+
return try {
40+
activity.startActivity(intent)
41+
true
42+
} catch (_: ActivityNotFoundException) {
43+
false
44+
}
45+
}
46+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.inappbrowsernitro.browser
2+
3+
import android.content.ComponentName
4+
import android.content.Context
5+
import android.content.Intent
6+
import androidx.browser.customtabs.CustomTabsClient
7+
import androidx.browser.customtabs.CustomTabsServiceConnection
8+
import androidx.browser.customtabs.CustomTabsSession
9+
import kotlinx.coroutines.suspendCancellableCoroutine
10+
import kotlin.coroutines.resume
11+
12+
internal class CustomTabsConnection(private val context: Context) {
13+
private var client: CustomTabsClient? = null
14+
private var session: CustomTabsSession? = null
15+
private var connection: CustomTabsServiceConnection? = null
16+
17+
suspend fun ensureSession(packageName: String?): CustomTabsSession? {
18+
session?.let { return it }
19+
20+
val targetPackage = CustomTabsPackageHelper.resolvePackage(context, packageName) ?: return null
21+
22+
return suspendCancellableCoroutine { continuation ->
23+
if (connection != null) {
24+
continuation.resume(session)
25+
return@suspendCancellableCoroutine
26+
}
27+
28+
val serviceConnection = object : CustomTabsServiceConnection() {
29+
override fun onCustomTabsServiceConnected(name: ComponentName, customTabsClient: CustomTabsClient) {
30+
client = customTabsClient
31+
client?.warmup(0L)
32+
session = client?.newSession(null)
33+
continuation.resume(session)
34+
}
35+
36+
override fun onServiceDisconnected(name: ComponentName) {
37+
client = null
38+
session = null
39+
}
40+
}
41+
42+
connection = serviceConnection
43+
44+
val bound = CustomTabsClient.bindCustomTabsService(context, targetPackage, serviceConnection)
45+
if (!bound) {
46+
connection = null
47+
continuation.resume(null)
48+
}
49+
50+
continuation.invokeOnCancellation {
51+
cleanup()
52+
}
53+
}
54+
}
55+
56+
fun warmup(packageName: String?) {
57+
val targetPackage = CustomTabsPackageHelper.resolvePackage(context, packageName) ?: return
58+
59+
if (client != null) {
60+
client?.warmup(0L)
61+
return
62+
}
63+
64+
val intent = Intent().apply {
65+
setPackage(targetPackage)
66+
}
67+
68+
context.bindService(intent, object : CustomTabsServiceConnection() {
69+
override fun onCustomTabsServiceConnected(name: ComponentName, customTabsClient: CustomTabsClient) {
70+
client = customTabsClient
71+
client?.warmup(0L)
72+
context.unbindService(this)
73+
}
74+
75+
override fun onServiceDisconnected(name: ComponentName) {
76+
client = null
77+
}
78+
}, Context.BIND_AUTO_CREATE)
79+
}
80+
81+
fun cleanup() {
82+
connection?.let { context.unbindService(it) }
83+
connection = null
84+
client = null
85+
session = null
86+
}
87+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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.DynamicColor
20+
import com.margelo.nitro.inappbrowsernitro.InAppBrowserOptions
21+
22+
internal class CustomTabsIntentFactory(
23+
private val context: Context,
24+
private val session: CustomTabsSession?
25+
) {
26+
fun create(options: InAppBrowserOptions?): CustomTabsIntent {
27+
val builder = session?.let { CustomTabsIntent.Builder(it) } ?: CustomTabsIntent.Builder()
28+
29+
applyColors(builder, options)
30+
applyBehaviours(builder, options)
31+
applyNavigation(builder, options)
32+
applyAnimations(builder, options?.animations)
33+
34+
val intent = builder.build()
35+
36+
configureIntent(intent.intent, options)
37+
38+
options?.browserPackage?.takeIf { it.isNotBlank() }?.let(intent.intent::setPackage)
39+
40+
return intent
41+
}
42+
43+
private fun applyColors(builder: CustomTabsIntent.Builder, options: InAppBrowserOptions?) {
44+
val toolbarParams = buildColorParams(options?.toolbarColor)
45+
if (toolbarParams != null) {
46+
builder.setDefaultColorSchemeParams(toolbarParams.system)
47+
builder.setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_LIGHT, toolbarParams.light)
48+
builder.setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, toolbarParams.dark)
49+
}
50+
51+
buildColorParams(options?.secondaryToolbarColor)?.systemColor?.let {
52+
builder.setSecondaryToolbarColor(it)
53+
}
54+
55+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
56+
buildColorParams(options?.navigationBarColor)?.systemColor?.let { color ->
57+
builder.setNavigationBarColor(color)
58+
}
59+
}
60+
61+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
62+
buildColorParams(options?.navigationBarDividerColor)?.systemColor?.let { color ->
63+
builder.setNavigationBarDividerColor(color)
64+
}
65+
}
66+
67+
options?.colorScheme?.let { scheme ->
68+
builder.setColorScheme(scheme.toCustomTabsScheme())
69+
}
70+
}
71+
72+
private fun applyBehaviours(builder: CustomTabsIntent.Builder, options: InAppBrowserOptions?) {
73+
builder.setShowTitle(options?.showTitle ?: true)
74+
75+
options?.enableUrlBarHiding?.let(builder::setUrlBarHidingEnabled)
76+
77+
when (options?.shareState) {
78+
BrowserShareState.ON -> builder.setShareState(CustomTabsIntent.SHARE_STATE_ON)
79+
BrowserShareState.OFF -> builder.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
80+
BrowserShareState.DEFAULT, null -> if (options?.enableDefaultShare == false) {
81+
builder.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
82+
}
83+
}
84+
85+
options?.instantAppsEnabled?.let(builder::setInstantAppsEnabled)
86+
87+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && options?.enablePartialCustomTab == true) {
88+
val height = (context.resources.displayMetrics.heightPixels * PARTIAL_TAB_RATIO).toInt()
89+
builder.setInitialActivityHeightPx(height, CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE)
90+
}
91+
92+
// Emulators rely on soft navigation buttons; keeping pull-to-refresh disabled avoids accidental reloads.
93+
if (options?.enablePullToRefresh == true) {
94+
builder.setUrlBarHidingEnabled(true)
95+
}
96+
}
97+
98+
private fun applyNavigation(builder: CustomTabsIntent.Builder, options: InAppBrowserOptions?) {
99+
if (options?.hasBackButton == true) {
100+
builder.setCloseButtonIcon(createBackArrow())
101+
}
102+
}
103+
104+
private fun applyAnimations(builder: CustomTabsIntent.Builder, animations: BrowserAnimations?) {
105+
animations ?: return
106+
val startEnter = resolveAnimation(animations.startEnter)
107+
val startExit = resolveAnimation(animations.startExit)
108+
if (startEnter != null && startExit != null) {
109+
builder.setStartAnimations(context, startEnter, startExit)
110+
}
111+
112+
val endEnter = resolveAnimation(animations.endEnter)
113+
val endExit = resolveAnimation(animations.endExit)
114+
if (endEnter != null && endExit != null) {
115+
builder.setExitAnimations(context, endEnter, endExit)
116+
}
117+
}
118+
119+
private fun configureIntent(intent: Intent, options: InAppBrowserOptions?) {
120+
if (options?.showInRecents == false) {
121+
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
122+
}
123+
124+
if (options?.includeReferrer == true) {
125+
val referrer = Uri.parse("android-app://${context.packageName}")
126+
intent.putExtra(Intent.EXTRA_REFERRER, referrer)
127+
}
128+
129+
options?.headers?.takeIf { it.isNotEmpty() }?.let { headers ->
130+
val bundle = Bundle()
131+
headers.forEach { (key, value) ->
132+
bundle.putString(key, value)
133+
}
134+
intent.putExtra(BROWSER_EXTRA_HEADERS, bundle)
135+
}
136+
}
137+
138+
private fun buildColorParams(color: DynamicColor?): ColorSchemeParams? {
139+
val system = DynamicColorResolver.resolveForScheme(color, DynamicColorResolver.DynamicScheme.SYSTEM)
140+
val light = DynamicColorResolver.resolveForScheme(color, DynamicColorResolver.DynamicScheme.LIGHT)
141+
val dark = DynamicColorResolver.resolveForScheme(color, DynamicColorResolver.DynamicScheme.DARK)
142+
143+
if (system == null && light == null && dark == null) {
144+
return null
145+
}
146+
147+
return ColorSchemeParams(
148+
system = CustomTabColorSchemeParams.Builder().apply {
149+
system?.let { setToolbarColor(it) }
150+
}.build(),
151+
light = CustomTabColorSchemeParams.Builder().apply {
152+
light?.let { setToolbarColor(it) }
153+
}.build(),
154+
dark = CustomTabColorSchemeParams.Builder().apply {
155+
dark?.let { setToolbarColor(it) }
156+
}.build(),
157+
systemColor = system
158+
)
159+
}
160+
161+
private fun resolveAnimation(name: String?): Int? {
162+
if (name.isNullOrBlank()) {
163+
return null
164+
}
165+
166+
val identifier = context.resources.getIdentifier(name, "anim", context.packageName)
167+
return identifier.takeIf { it != 0 }
168+
}
169+
170+
private fun createBackArrow(): Bitmap {
171+
val size = context.resources.displayMetrics.density * 24
172+
val bitmap = Bitmap.createBitmap(size.toInt(), size.toInt(), Bitmap.Config.ARGB_8888)
173+
val canvas = Canvas(bitmap)
174+
175+
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
176+
color = DEFAULT_CLOSE_BUTTON_COLOR
177+
style = Paint.Style.STROKE
178+
strokeWidth = context.resources.displayMetrics.density * 2
179+
strokeCap = Paint.Cap.ROUND
180+
strokeJoin = Paint.Join.ROUND
181+
}
182+
183+
val path = Path().apply {
184+
moveTo(size * 0.75f, size * 0.2f)
185+
lineTo(size * 0.35f, size * 0.5f)
186+
lineTo(size * 0.75f, size * 0.8f)
187+
}
188+
189+
canvas.drawPath(path, paint)
190+
return bitmap
191+
}
192+
193+
private data class ColorSchemeParams(
194+
val system: CustomTabColorSchemeParams,
195+
val light: CustomTabColorSchemeParams,
196+
val dark: CustomTabColorSchemeParams,
197+
val systemColor: Int?,
198+
)
199+
200+
private fun BrowserColorScheme.toCustomTabsScheme(): Int {
201+
return when (this) {
202+
BrowserColorScheme.LIGHT -> CustomTabsIntent.COLOR_SCHEME_LIGHT
203+
BrowserColorScheme.DARK -> CustomTabsIntent.COLOR_SCHEME_DARK
204+
BrowserColorScheme.SYSTEM -> CustomTabsIntent.COLOR_SCHEME_SYSTEM
205+
}
206+
}
207+
208+
private companion object {
209+
private const val PARTIAL_TAB_RATIO = 0.85f
210+
private const val BROWSER_EXTRA_HEADERS = "android.support.customtabs.extra.EXTRA_HEADERS"
211+
private val DEFAULT_CLOSE_BUTTON_COLOR = Color.argb(0xFF, 0x3A, 0x3A, 0x3A)
212+
}
213+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.inappbrowsernitro.browser
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.net.Uri
6+
import androidx.browser.customtabs.CustomTabsClient
7+
8+
internal object CustomTabsPackageHelper {
9+
fun resolvePackage(context: Context, preferred: String?): String? {
10+
if (!preferred.isNullOrBlank()) {
11+
return preferred
12+
}
13+
14+
return CustomTabsClient.getPackageName(context, buildIntent())
15+
}
16+
17+
private fun buildIntent(): Intent {
18+
return Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com"))
19+
}
20+
}

0 commit comments

Comments
 (0)