Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ metadata:
builder:
configs:
- documentation_targets:
- 'AndroidContext'
- 'AndroidAssetManager'
- 'AndroidLogging'
- 'AndroidLooper'
Expand Down
12 changes: 12 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ let package = Package(
],
products: [
.library(name: "AndroidNative", targets: ["AndroidNative"]),
.library(name: "AndroidContext", targets: ["AndroidContext"]),
.library(name: "AndroidAssetManager", targets: ["AndroidAssetManager"]),
.library(name: "AndroidLogging", targets: ["AndroidLogging"]),
.library(name: "AndroidLooper", targets: ["AndroidLooper"]),
Expand Down Expand Up @@ -90,6 +91,17 @@ let package = Package(
dependencies: [
"AndroidAssetManager"
]),
.target(
name: "AndroidContext",
dependencies: [
"AndroidAssetManager",
.product(name: "SwiftJavaJNICore", package: "swift-java-jni-core"),
]),
.testTarget(
name: "AndroidContextTests",
dependencies: [
"AndroidContext"
]),
.target(
name: "AndroidLogging",
dependencies: [
Expand Down
104 changes: 84 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ dependencies: [
]
```

# JNI Dependencies and SwiftJava Interoperability

This package depends only on [swiftlang/swift-java-jni-core](https://github.com/swiftlang/swift-java-jni-core),
a lightweight module that provides the JNI type definitions (`jobject`, `jclass`, `JNIEnvironment`, etc.),
the `JavaVirtualMachine` lifecycle manager, and the raw `JNINativeInterface` function table.
It does *not* depend on the full [swiftlang/swift-java](https://github.com/swiftlang/swift-java) bridge
or its higher-level abstractions (`JavaObject`, `JavaClass`, generated Java-to-Swift wrappers, etc.).

This means SwiftAndroidNative can be used in projects that only need direct JNI access
without pulling in the larger swift-java dependency graph.

However, SwiftAndroidNative is designed to optionally interoperate with swift-java.
Because both packages share the same underlying JNI types from swift-java-jni-core,
a `jobject` obtained through SwiftAndroidNative (such as `AndroidContext.pointer`) can be
passed directly to swift-java bridged APIs, and vice versa. For example, a context
`jobject` returned by a swift-java generated bridge class can be handed to
`AndroidContext.setSharedContext(_:env:)`, and an `AndroidContext.pointer` can be wrapped
in a swift-java `JavaObjectHolder` for use with generated Java class bindings.

If your project uses swift-java, add it as a separate dependency alongside swift-android-native;
the two will share the same `JavaVirtualMachine` instance and JNI environment without conflict.


# AndroidLogging

This module provides a Logger API for native Swift on Android compatible with
Expand Down Expand Up @@ -90,7 +113,8 @@ function.
# AndroidContext

This module provides a minimal wrapper for [android.content.Context](https://developer.android.com/reference/android/content/Context)
that uses [SwiftJNI](https://github.com/skiptools/swift-jni) to bridge into the global application context.
that uses raw JNI calls (via [SwiftJavaJNICore](https://github.com/swiftlang/swift-java-jni-core))
to bridge into the global application context.

## Installation

Expand All @@ -109,37 +133,77 @@ let context = try AndroidContext.application
let packageName = try context.getPackageName()
```

## Internals
## Bootstrapping the Context

### Implementation details
The recommended way to initialize `AndroidContext` is to call `setSharedContext(_:env:)`
as early as possible — before any code accesses `AndroidContext.application`. This avoids
the automatic JVM lookup and reflective factory call entirely, giving you full control over
how the context is provided.

### From `JNI_OnLoad`

By default, the `AndroidContext.application` accessor will try to invoke the JNI method
`android.app.ActivityThread.currentApplication()Landroid/app/Application;` to obtain the
global application context. This can be overridden at app initialization time by setting
the `SWIFT_ANDROID_CONTEXT_FACTORY` environment to a different static accessor, such as:
If your Swift code is loaded as a shared library by Java (e.g. via `System.loadLibrary`),
implement the standard `JNI_OnLoad` entry point. The `JavaVM` pointer gives you a
`JNIEnvironment`, and you can then look up the application context:

```swift
// another way to access the global context (deprecated)
setenv("SWIFT_ANDROID_CONTEXT_FACTORY", "android.app.AppGlobals.getInitialApplication()Landroid/app/Application;", 1)
import SwiftJavaJNICore
import AndroidContext

@_cdecl("JNI_OnLoad")
public func JNI_OnLoad(_ jvm: UnsafeMutablePointer<JavaVM?>, _ reserved: UnsafeMutableRawPointer?) -> jint {
// Adopt the JVM so SwiftJavaJNICore knows about it
let vm = JavaVirtualMachine(adoptingJVM: jvm)
JavaVirtualMachine.setSharedJVM(vm)
let env = try! vm.environment()
let jni = env.pointee!.pointee

// Look up the application context via ActivityThread
let cls = jni.FindClass(env, "android/app/ActivityThread")!
let mid = jni.GetStaticMethodID(env, cls, "currentApplication", "()Landroid/app/Application;")!
let app = jni.CallStaticObjectMethodA(env, cls, mid, [])!
let globalRef = jni.NewGlobalRef(env, app)! // prevent GC

AndroidContext.setSharedContext(globalRef, env: env)
return jint(JNI_VERSION_1_6)
}
```

let context = try AndroidContext.application
### From SwiftJava / swift-java bridged code

If you are using the full [swift-java](https://github.com/swiftlang/swift-java) bridge,
the JVM is already set up for you. You can obtain the environment from
`JavaVirtualMachine.shared()` and pass in a context `jobject` from the bridged Java side:

```swift
let jvm = try JavaVirtualMachine.shared()
let env = try jvm.environment()
AndroidContext.setSharedContext(someContextJobject, env: env)
```

Such setup must be performed before the first time the `AndroidContext.application`
accessor is called, as the result will be cached the first time it is invoked.
### From an `ANativeActivity`

Alternatively, if the application bootstrapping code already has access to a
JNI context and `jobject` reference to the application context, it can be
set directly in the static `contextPointer` field. For example,
if your application uses an NDK [ANativeActivity](https://developer.android.com/ndk/reference/struct/a-native-activity)
activity, then the context can be accessed from its reference to the underlying
[android.app.NativeActivity](https://developer.android.com/reference/android/app/NativeActivity)
instance:
If your application uses an NDK [ANativeActivity](https://developer.android.com/ndk/reference/struct/a-native-activity),
you can set the context pointer directly using the (misnamed) [`clazz`](https://developer.android.com/ndk/reference/struct/a-native-activity#struct_a_native_activity_1abbde1ec6b9af24c517a604f0d401b274) pointer:

```swift
let nativeActivity: ANativeActivity = …
AndroidContext.contextPointer = nativeActivity.clazz
let context = try AndroidContext.application // returns the wrapper around the application context
let context = try AndroidContext.application
```

### Automatic fallback

If `setSharedContext` is never called and `contextPointer` is not set,
`AndroidContext.application` will attempt to locate the JVM automatically using
`JavaVirtualMachine.shared()` and then reflectively invoke the factory method
`android.app.ActivityThread.currentApplication()` to obtain the global context.
This can be overridden by setting the `SWIFT_ANDROID_CONTEXT_FACTORY` environment
variable to a different static accessor before the first access:

```swift
setenv("SWIFT_ANDROID_CONTEXT_FACTORY", "android.app.AppGlobals.getInitialApplication()Landroid/app/Application;", 1)
let context = try AndroidContext.application
```


Expand Down
101 changes: 85 additions & 16 deletions Sources/AndroidContext/AndroidContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ import func Darwin.getenv
#elseif canImport(Glibc)
import func Glibc.getenv
#endif
@_exported import AndroidAssetManager
import SwiftJNI
import SwiftJavaJNICore
public import AndroidAssetManager

/// A native reference to
/// [android.content.Context](https://developer.android.com/reference/android/content/Context)
//@available(macOS, unavailable)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public class AndroidContext: JObject, @unchecked Sendable {
public class AndroidContext: @unchecked Sendable {
/// The JNI signature for the method to invoke to obtain the global Context.
/// This can be manually changed before initialization to a different signature.
/// It must be a zero-argument static fuction that returns an instance of `android.content.Context`.
Expand All @@ -43,23 +43,59 @@ public class AndroidContext: JObject, @unchecked Sendable {
public static var contextFactory = getenv("SWIFT_ANDROID_CONTEXT_FACTORY").flatMap({ String(cString: $0) }) ?? "android.app.ActivityThread.currentApplication()Landroid/app/Application;"

/// A global pointer to the application context, in case the application environment wants to initialize it directly without going through the factory method.
public static var contextPointer: JavaObjectPointer? = nil
public static var contextPointer: jobject? = nil

/// The underlying JNI object pointer for this context.
public let pointer: jobject

/// The JNI environment used by this context.
private let env: JNIEnvironment

/// Initialize from an existing JNI object pointer and environment.
public init(pointer: jobject, env: JNIEnvironment) {
self.pointer = pointer
self.env = env
}

/// Sets a pre-initialized Android context directly, bypassing the automatic JVM and context
/// lookup performed by the `application` accessor.
///
/// Call this method early in your application's lifecycle — for example, from a `JNI_OnLoad`
/// function or an `ANativeActivity` callback — before any code accesses `AndroidContext.application`.
/// Once the shared context is set, `application` will return it immediately without attempting to
/// locate the JVM or invoke the `contextFactory` reflective lookup.
///
/// - Parameter context: A JNI `jobject` reference to an `android.content.Context` (or subclass
/// such as `android.app.Application`). The caller is responsible for ensuring this reference
/// remains valid for the lifetime of the process (typically a global ref).
/// - Parameter env: The JNI environment for the current thread.
public static func setSharedContext(_ context: jobject, env: JNIEnvironment) {
sharedContext = AndroidContext(pointer: context, env: env)
}

/// A manually provided shared context, set via `setSharedContext(_:env:)`.
private static var sharedContext: AndroidContext? = nil

/// Returns the application context.
public static var application: AndroidContext {
get throws {
try applicationContext.get()
if let sharedContext = sharedContext {
return sharedContext
}
return try applicationContext.get()
}
}

/// Obtain the global application context by checking whether the static `contextPointer` is set,
/// and if not, using the `contextFactory` string to reflectively look up the global context.
private static let applicationContext: Result<AndroidContext, Error> = Result(catching: {
try JNI.attachJVM() // ensure that we have a JNI context
let jvm: JavaVirtualMachine = try JavaVirtualMachine.shared()
let env: JNIEnvironment = try jvm.environment()
let jni: JNINativeInterface = env.pointee!.pointee

// if we have provided a manual context jobject, then we just use that and skip trying to access the factory
if let contextPointer = contextPointer {
return AndroidContext(contextPointer)
return AndroidContext(pointer: contextPointer, env: env)
}

// alternative fallback mechanism:
Expand All @@ -79,24 +115,57 @@ public class AndroidContext: JObject, @unchecked Sendable {
let contextMethod = "" + contextFunctionParts[0]
let contextSig = "(" + contextFunctionParts[1]

let cls = try JClass(name: contextType)
guard let mth = cls.getStaticMethodID(name: contextMethod, sig: contextSig) else {
// Convert class name from dot notation to slash notation for JNI
let jniClassName = contextType.split(separator: ".").joined(separator: "/")

guard let cls: jclass = jni.FindClass(env, jniClassName) else {
throw ContextError(errorDescription: "Unable to find class \(contextType)")
}

guard let mth: jmethodID = jni.GetStaticMethodID(env, cls, contextMethod, contextSig) else {
throw ContextError(errorDescription: "Unable to find method \(contextMethod)")
}
let ctx: JavaObjectPointer = try cls.callStatic(method: mth, options: [], args: [])
return AndroidContext(ctx)
})

private static let javaClass = try! JClass(name: "android/content/Context", systemClass: true)
guard let ctx: jobject = jni.CallStaticObjectMethodA(env, cls, mth, []) else {
throw ContextError(errorDescription: "Factory method \(contextMethod) returned null")
}

return AndroidContext(pointer: ctx, env: env)
})

/// The `AndroidAssetManager` for this context
public private(set) lazy var assetManager = JNI.jni.withEnv { _, env in AndroidAssetManager(env: env, peer: self.safePointer()) }
public private(set) lazy var assetManager: AndroidAssetManager = {
let jni: JNINativeInterface = env.pointee!.pointee

// Call context.getAssets() to get the Java AssetManager
let contextClass: jclass = jni.GetObjectClass(env, pointer)!
let getAssetsID: jmethodID = jni.GetMethodID(env, contextClass, "getAssets", "()Landroid/content/res/AssetManager;")!
let assetManagerObj: jobject = jni.CallObjectMethodA(env, pointer, getAssetsID, [])!

return AndroidAssetManager(env: env, peer: assetManagerObj)
}()

/// Returns the package name for the current context
public func getPackageName() throws -> String? {
try call(method: Self.getPackageNameID, options: [], args: [])
let jni: JNINativeInterface = env.pointee!.pointee

let contextClass: jclass = jni.GetObjectClass(env, pointer)!
guard let getPackageNameID: jmethodID = jni.GetMethodID(env, contextClass, "getPackageName", "()Ljava/lang/String;") else {
throw ContextError(errorDescription: "Unable to find getPackageName method")
}

guard let javaString: jobject = jni.CallObjectMethodA(env, pointer, getPackageNameID, []) else {
return nil
}

// Convert Java String to Swift String
guard let utf8Chars = jni.GetStringUTFChars(env, javaString, nil) else {
return nil
}
let result = String(cString: utf8Chars)
jni.ReleaseStringUTFChars(env, javaString, utf8Chars)
return result
}
private static let getPackageNameID = javaClass.getMethodID(name: "getPackageName", sig: "()Ljava/lang/String;")!

struct ContextError: LocalizedError {
var errorDescription: String?
Expand Down
2 changes: 1 addition & 1 deletion Sources/OSLog/AndroidLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Android
import AndroidNDK
#endif

#if canImport(os)
#if canImport(OSLog)
@_exported import OSLog
#else
import AndroidLogging
Expand Down
3 changes: 1 addition & 2 deletions Tests/AndroidContextTests/AndroidContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@

import Testing
import AndroidContext
import AndroidAssetManager
import SwiftJavaJNICore
#if os(Android)
import AndroidNDK
#endif

#if !os(iOS)
struct AndroidContextTests {
// TODO: activate these tests now that we have `skip android test --apk` and can access the JNI context
@Test(.disabled("this test is only for demo purposes"))
func testAndroidContext() throws {
#if os(Android)
let nativeActivity: ANativeActivity! = nil
Expand Down
8 changes: 6 additions & 2 deletions Tests/AndroidLoggingTests/AndroidLoggingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@
//===----------------------------------------------------------------------===//

import Testing
#if canImport(OSLog)
import OSLog // note: on non-android platforms, this will just export the system OSLog
#else
import AndroidLogging
#endif

struct AndroidLoggingTests {
@Test func testOSLogAPI() {
let emptyLogger = Logger()
emptyLogger.info("Android logger test: empty message")
//let emptyLogger = Logger()
//emptyLogger.info("Android logger test: empty message")

let logger = Logger(subsystem: "AndroidLoggingTests", category: "test")

Expand Down
Loading