diff --git a/android/build.gradle b/android/build.gradle index 6ae1341..6c18a87 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "1.9.24" + def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.1.20" repositories { google() @@ -13,10 +13,6 @@ buildscript { } } -def isNewArchitectureEnabled() { - return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" -} - def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } @@ -26,34 +22,25 @@ apply plugin: "com.facebook.react" apply plugin: "kotlin-android" android { - compileSdkVersion safeExtGet("compileSdkVersion", 35) + namespace "com.xmartlabs.line" + compileSdk safeExtGet("compileSdkVersion", 36) defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 24) - targetSdkVersion safeExtGet("targetSdkVersion", 35) + minSdk safeExtGet("minSdkVersion", 24) + targetSdk safeExtGet("targetSdkVersion", 36) versionCode 1 versionName "5.6.0" } sourceSets { main { - java.srcDirs += ["${project.buildDir}/generated/source/codegen/java"] + java.srcDirs += ["${layout.buildDirectory.get().asFile}/generated/source/codegen/java"] } } } dependencies { implementation "com.facebook.react:react-native:+" - implementation "com.linecorp.linesdk:linesdk:5.11.0" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + implementation "com.linecorp.linesdk:linesdk:5.12.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" } - -tasks.register("checkNativeLineLoginSpec") { - doLast { - if (!isNewArchitectureEnabled()) { - throw new GradleException("@xmartlabs/react-native-line v5 requires your project to have NEW ARCHITECTURE ENABLED. Use v4 if you want to keep using the old architecture.") - } - } -} - -preBuild.dependsOn checkNativeLineLoginSpec diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 84f5483..94cbbcf 100755 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1 @@ - + diff --git a/android/src/main/java/com/xmartlabs/line/LineLoginModule.kt b/android/src/main/java/com/xmartlabs/line/LineLoginModule.kt index 106313e..e5bb54c 100755 --- a/android/src/main/java/com/xmartlabs/line/LineLoginModule.kt +++ b/android/src/main/java/com/xmartlabs/line/LineLoginModule.kt @@ -1,8 +1,9 @@ package com.xmartlabs.line import android.app.Activity -import android.content.Context import android.content.Intent +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import com.facebook.react.bridge.* import com.facebook.react.module.annotations.ReactModule @@ -14,298 +15,280 @@ import com.linecorp.linesdk.auth.LineAuthenticationConfig import com.linecorp.linesdk.auth.LineAuthenticationParams import com.linecorp.linesdk.auth.LineLoginApi import com.linecorp.linesdk.auth.LineLoginResult -import com.linecorp.linesdk.LineProfile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -private var LOGIN_REQUEST_CODE: Int = 0 - -enum class LoginArguments(val key: String) { - BOT_PROMPT("botPrompt"), - ONLY_WEB_LOGIN("onlyWebLogin"), - SCOPES("scopes") -} - -class LineLoginModule(private val reactContext: ReactApplicationContext) : +@ReactModule(name = LineLoginModule.NAME) +class LineLoginModule(reactContext: ReactApplicationContext) : NativeLineLoginSpec(reactContext) { companion object { + const val BOT_PROMPT = "botPrompt" + const val CHANNEL_ID = "channelId" + const val ONLY_WEB_LOGIN = "onlyWebLogin" + const val SCOPES = "scopes" + const val NAME = NativeLineLoginSpec.NAME + private const val LOGIN_REQUEST_CODE = 1423 + private val DEFAULT_SCOPES = listOf("profile") } - private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val pendingLogin = AtomicReference(null) - private lateinit var channelId: String - private lateinit var lineApiClient: LineApiClient - private var loginResult: Promise? = null + @Volatile private var channelId: String? = null + @Volatile private var lineApiClient: LineApiClient? = null - override fun setup(args: ReadableMap, promise: Promise) { - val channelIdArg = args.getString("channelId") - if (channelIdArg == null) { - return promise.reject("SETUP_FAILED", "channelId is required", null) - } - channelId = channelIdArg - lineApiClient = LineApiClientBuilder(reactContext.applicationContext, channelId).build() - try { - reactContext.addActivityEventListener(object : ActivityEventListener { - override fun onNewIntent(intent: Intent) {} - override fun onActivityResult( - activity: Activity, - requestCode: Int, - resultCode: Int, - data: Intent? - ) = - handleActivityResult(requestCode, resultCode, data) - }) - promise.resolve(null) - } catch (e: Throwable) { - promise.reject("SETUP_FAILED", e.message, e) + private val activityListenerRegistered = AtomicBoolean(false) + + override fun invalidate() { + scope.cancel() + pendingLogin.getAndSet(null) + ?.reject("MODULE_INVALIDATED", "Module was destroyed during login", null) + if (activityListenerRegistered.getAndSet(false)) { + reactApplicationContext.removeActivityEventListener(loginResultListener) } + super.invalidate() } - override fun login(args: ReadableMap, promise: Promise) { - val scopes = - if (args.hasKey(LoginArguments.SCOPES.key)) args.getArray(LoginArguments.SCOPES.key)!! - .toArrayList() as List else listOf("profile") - val onlyWebLogin = - args.hasKey(LoginArguments.ONLY_WEB_LOGIN.key) && args.getBoolean(LoginArguments.ONLY_WEB_LOGIN.key) - val botPromptString = - if (args.hasKey(LoginArguments.BOT_PROMPT.key)) args.getString(LoginArguments.BOT_PROMPT.key)!! else "normal" - login( - scopes, - onlyWebLogin, - botPromptString, - promise - ) - } + override fun setup(args: ReadableMap, promise: Promise) { + val id = args.getString(CHANNEL_ID)?.takeIf { it.isNotBlank() } + ?: return promise.reject("SETUP_FAILED", "channelId must be a non-empty string", null) - private fun login( - scopes: List, - onlyWebLogin: Boolean, - botPromptString: String, - promise: Promise - ) { + channelId = id - val lineAuthenticationParams = LineAuthenticationParams.Builder() - .scopes(Scope.convertToScopeList(scopes)) - .apply { - botPrompt(LineAuthenticationParams.BotPrompt.valueOf(botPromptString)) - } - .build() + lineApiClient = LineApiClientBuilder(reactApplicationContext, id).build() - val lineAuthenticationConfig: LineAuthenticationConfig? = - createLineAuthenticationConfig(channelId, onlyWebLogin) + if (activityListenerRegistered.compareAndSet(false, true)) { + reactApplicationContext.addActivityEventListener(loginResultListener) + } + promise.resolve(null) + } - val activity: Activity = currentActivity!! + override fun login(args: ReadableMap, promise: Promise) { + requireClient(promise) ?: return - val loginIntent = - when { - lineAuthenticationConfig != null -> LineLoginApi.getLoginIntent( - activity, - lineAuthenticationConfig, - lineAuthenticationParams - ) + val id = channelId ?: return promise.reject("NOT_SETUP", "Call setup() first", null) - onlyWebLogin -> LineLoginApi.getLoginIntentWithoutLineAppAuth( - activity, channelId, lineAuthenticationParams - ) + val scopes = args.getArray(SCOPES) + ?.toArrayList()?.filterIsInstance()?.takeIf { it.isNotEmpty() } + ?: DEFAULT_SCOPES - else -> LineLoginApi.getLoginIntent(activity, channelId, lineAuthenticationParams) - } + val onlyWebLogin = args.hasKey(ONLY_WEB_LOGIN) && args.getBoolean(ONLY_WEB_LOGIN) - activity.startActivityForResult(loginIntent, LOGIN_REQUEST_CODE) - this.loginResult = promise - } + val botPromptRaw = args.getString(BOT_PROMPT) ?: "normal" + val botPrompt = LineAuthenticationParams.BotPrompt.entries + .find { it.name.equals(botPromptRaw, ignoreCase = true) } + ?: return promise.reject( + "INVALID_ARGUMENT", + "Invalid botPrompt '$botPromptRaw'. Expected: ${ + LineAuthenticationParams.BotPrompt.entries.joinToString { it.name.lowercase() } + }", + null, + ) - override fun getProfile(promise: Promise) { - coroutineScope.launch { - val lineApiResponse = withContext(Dispatchers.IO) { lineApiClient.getProfile() } - if (!lineApiResponse.isSuccess) { - promise.reject( - lineApiResponse.responseCode.name, - lineApiResponse.errorData.message, - null - ) - } else { - promise.resolve(parseProfile(lineApiResponse.responseData)) - } + val activity = currentActivity + ?: return promise.reject("NO_ACTIVITY", "Activity is not available", null) + + if (!pendingLogin.compareAndSet(null, promise)) { + return promise.reject("LOGIN_IN_PROGRESS", "A login is already in progress", null) } - } - fun handleActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { - if (requestCode != LOGIN_REQUEST_CODE) return + val authParams = LineAuthenticationParams.Builder() + .scopes(Scope.convertToScopeList(scopes)) + .botPrompt(botPrompt) + .build() + + val config = if (onlyWebLogin) { + LineAuthenticationConfig.Builder(id).disableLineAppAuthentication().build() + } else null - if (resultCode != Activity.RESULT_OK || intent == null) { - loginResult?.reject( - resultCode.toString(), - "ERROR", - null - ) + val intent = if (config != null) { + LineLoginApi.getLoginIntent(activity, config, authParams) + } else { + LineLoginApi.getLoginIntent(activity, id, authParams) } - val result = LineLoginApi.getLoginResultFromIntent(intent) - - when (result.responseCode) { - LineApiResponseCode.SUCCESS -> { - loginResult?.resolve(parseLoginResult(result)) - loginResult = null - } + activity.startActivityForResult(intent, LOGIN_REQUEST_CODE) + } - LineApiResponseCode.CANCEL -> { - loginResult?.reject( - result.responseCode.name, - result.errorData.message, - null - ) + private val loginResultListener = object : ActivityEventListener { + override fun onNewIntent(intent: Intent) = Unit + + override fun onActivityResult( + activity: Activity, + requestCode: Int, + resultCode: Int, + data: Intent? + ) { + if (requestCode != LOGIN_REQUEST_CODE) return + val promise = pendingLogin.getAndSet(null) ?: return + + if (resultCode != Activity.RESULT_OK || data == null) { + val (code, message) = if (resultCode == Activity.RESULT_CANCELED) { + "LOGIN_CANCELLED" to "Login was cancelled by the user" + } else { + "LOGIN_FAILED" to "Login failed with unexpected result code $resultCode" + } + return promise.reject(code, message, null) } - else -> { - loginResult?.reject( + val result = LineLoginApi.getLoginResultFromIntent(data) + if (result.responseCode == LineApiResponseCode.SUCCESS) { + val cred = result.lineCredential + val prof = result.lineProfile + if (cred != null && prof != null) { + promise.resolve(buildLoginResult(result, cred, prof)) + } else { + promise.reject( + "PARSE_ERROR", + "Credential or profile missing from login result", + null + ) + } + } else { + promise.reject( result.responseCode.name, - result.errorData.message, + result.errorData.message ?: result.responseCode.name, null ) } } - - loginResult = null } override fun logout(promise: Promise) { - coroutineScope.launch { - val lineApiResponse = withContext(Dispatchers.IO) { lineApiClient.logout() } - if (lineApiResponse.isSuccess) { - promise.resolve(null) - } else { - promise.reject( - lineApiResponse.responseCode.name, - lineApiResponse.errorData.message, + val client = requireClient(promise) ?: return + scope.launch { + try { + val r = client.logout() + if (r.isSuccess) promise.resolve(null) + else promise.reject( + r.responseCode.name, + r.errorData.message ?: r.responseCode.name, null ) + } catch (e: Exception) { + promise.reject("API_ERROR", e.message ?: "Unexpected error during logout", e) } } } - override fun getCurrentAccessToken(promise: Promise) = invokeLineServiceMethod( - promise = promise, - serviceCallable = { lineApiClient.getCurrentAccessToken() }, - parser = { parseAccessToken(it, lineIdToken = null) } + override fun getProfile(promise: Promise) = apiCall( + promise, + call = LineApiClient::getProfile, + build = ::buildProfile, + ) + + override fun getCurrentAccessToken(promise: Promise) = apiCall( + promise, + call = LineApiClient::getCurrentAccessToken, + build = { buildAccessToken(it, idToken = null) }, ) - override fun getFriendshipStatus(promise: Promise) = invokeLineServiceMethod( - promise = promise, - serviceCallable = { lineApiClient.getFriendshipStatus() }, - parser = { parseFriendshipStatus(it) } + override fun getFriendshipStatus(promise: Promise) = apiCall( + promise, + call = LineApiClient::getFriendshipStatus, + build = ::buildFriendshipStatus, ) - override fun refreshAccessToken(promise: Promise) = invokeLineServiceMethod( - promise = promise, - serviceCallable = { lineApiClient.refreshAccessToken() }, - parser = { parseAccessToken(it, lineIdToken = null) } + override fun refreshAccessToken(promise: Promise) = apiCall( + promise, + call = LineApiClient::refreshAccessToken, + build = { buildAccessToken(it, idToken = null) }, ) - override fun verifyAccessToken(promise: Promise) = invokeLineServiceMethod( - promise = promise, - serviceCallable = { lineApiClient.verifyToken() }, - parser = { parseVerifyAccessToken(it) } + override fun verifyAccessToken(promise: Promise) = apiCall( + promise, + call = LineApiClient::verifyToken, + build = ::buildVerifyResult, ) - private fun invokeLineServiceMethod( + /** + * Returns the initialized [LineApiClient] or rejects [promise] with NOT_SETUP + * and returns null, allowing callers to use the `?: return` pattern. + */ + private fun requireClient(promise: Promise): LineApiClient? = + lineApiClient ?: run { + promise.reject("NOT_SETUP", "Call setup() before using the LINE SDK", null) + null + } + + /** + * Runs a blocking LINE API [call] on the IO dispatcher. + * Resolves [promise] via [build] on success, or rejects it with the LINE error. + */ + private fun apiCall( promise: Promise, - serviceCallable: () -> LineApiResponse, - parser: (T) -> WritableMap + call: (LineApiClient) -> LineApiResponse, + build: (T) -> WritableMap, ) { - coroutineScope.launch { - val lineApiResponse = withContext(Dispatchers.IO) { serviceCallable.invoke() } - if (lineApiResponse.isSuccess) { - promise.resolve(parser.invoke(lineApiResponse.responseData)) - } else { - promise.reject( - lineApiResponse.responseCode.name, - lineApiResponse.errorData.message, + val client = requireClient(promise) ?: return + scope.launch { + try { + val r = call(client) + if (r.isSuccess) promise.resolve(build(r.responseData)) + else promise.reject( + r.responseCode.name, + r.errorData.message ?: r.responseCode.name, null ) + } catch (e: Exception) { + promise.reject("API_ERROR", e.message ?: "Unexpected error", e) } } } - - private fun parseAccessToken( - accessToken: LineAccessToken, - lineIdToken: LineIdToken? - ): WritableMap = Arguments.makeNativeMap( - mapOf( - "accessToken" to accessToken.tokenString, - "expiresIn" to accessToken.expiresInMillis, - "idToken" to lineIdToken?.rawString - ) - ) - - private fun parseFriendshipStatus(friendshipStatus: LineFriendshipStatus): WritableMap = + /** + * [expiresIn] is seconds-until-expiry (OAuth standard `expires_in`), + * derived from the SDK's millisecond value. + */ + private fun buildAccessToken(token: LineAccessToken, idToken: LineIdToken?): WritableMap = Arguments.makeNativeMap( mapOf( - "friendFlag" to friendshipStatus.isFriend + "accessToken" to token.tokenString, + "expiresIn" to token.expiresInMillis / 1000L, + "idToken" to idToken?.rawString, ) ) - private fun parseProfile(profile: LineProfile): WritableMap = Arguments.makeNativeMap( - mapOf( - "displayName" to profile.displayName, - "pictureUrl" to profile.pictureUrl?.toString(), - "statusMessage" to profile.statusMessage, - "userId" to profile.userId + private fun buildProfile(profile: LineProfile): WritableMap = + Arguments.makeNativeMap( + mapOf( + "displayName" to profile.displayName, + "pictureUrl" to profile.pictureUrl?.toString(), + "statusMessage" to profile.statusMessage, + "userId" to profile.userId, + ) ) - ) - private fun parseLoginResult(loginResult: LineLoginResult): WritableMap = + private fun buildFriendshipStatus(status: LineFriendshipStatus): WritableMap = Arguments.makeNativeMap( mapOf( - "accessToken" to parseAccessToken( - loginResult.lineCredential!!.accessToken, - loginResult.lineIdToken - ), - "friendshipStatusChanged" to loginResult.friendshipStatusChanged, - "idTokenNonce" to loginResult.lineIdToken?.nonce, - "scope" to loginResult.lineCredential?.scopes?.let { - Scope.join(it) - }, - "userProfile" to parseProfile(loginResult.lineProfile!!) + "friendFlag" to status.isFriend ) ) - private fun parseVerifyAccessToken(verifyAccessToken: LineCredential): WritableMap = + private fun buildLoginResult(result: LineLoginResult, credential: LineCredential, profile: LineProfile): WritableMap = Arguments.makeNativeMap( mapOf( - "clientId" to channelId, - "expiresIn" to verifyAccessToken.accessToken.expiresInMillis, - "scope" to Scope.join(verifyAccessToken.scopes) + "accessToken" to buildAccessToken(credential.accessToken, result.lineIdToken), + "friendshipStatusChanged" to result.friendshipStatusChanged, + "idTokenNonce" to result.lineIdToken?.let { result.nonce }, + "scope" to Scope.join(credential.scopes), + "userProfile" to buildProfile(profile), ) ) - - private fun createConfig( - channelId: String, - isLineAppAuthDisabled: Boolean - ): LineAuthenticationConfig { - val configBuilder = LineAuthenticationConfig.Builder(channelId) - - if (isLineAppAuthDisabled) { - configBuilder.disableLineAppAuthentication() - } - - return configBuilder.build() - } - - private fun createLineAuthenticationConfig( - channelId: String, - onlyWebLogin: Boolean - ): LineAuthenticationConfig? { - return createConfig( - channelId, - onlyWebLogin + private fun buildVerifyResult(credential: LineCredential): WritableMap = + Arguments.makeNativeMap( + mapOf( + "clientId" to channelId, + "expiresIn" to credential.accessToken.expiresInMillis / 1000L, + "scope" to Scope.join(credential.scopes), + ) ) - } } diff --git a/android/src/main/java/com/xmartlabs/line/LineLoginPackage.kt b/android/src/main/java/com/xmartlabs/line/LineLoginPackage.kt index 2c8025f..fcc8c2b 100755 --- a/android/src/main/java/com/xmartlabs/line/LineLoginPackage.kt +++ b/android/src/main/java/com/xmartlabs/line/LineLoginPackage.kt @@ -21,7 +21,7 @@ class LineLoginPackage : TurboReactPackage() { LineLoginModule.NAME, canOverrideExistingModule = false, needsEagerInit = false, - hasConstants = true, + hasConstants = false, isCxxModule = false, isTurboModule = true ) diff --git a/src/NativeLineLogin.ts b/src/NativeLineLogin.ts index 473e445..1ba68cb 100644 --- a/src/NativeLineLogin.ts +++ b/src/NativeLineLogin.ts @@ -1,126 +1,156 @@ +/** + * Native TurboModule specification for LINE Login. + * + * This file is consumed by React Native code generation — do not change method + * signatures without regenerating the native specs. + * + * @see https://developers.line.biz/en/docs/line-login/ + */ import { TurboModule, TurboModuleRegistry } from 'react-native' +// ─── Enums ─────────────────────────────────────────────────────────────────── + +/** Controls whether the user is prompted to add the LINE Official Account as a friend during login. */ export enum BotPrompt { - /** Opens a new screen to add a LINE Official Account as a friend after the user agrees to the permissions in the consent screen. */ + /** Shows a standalone screen after the consent screen asking the user to add the bot. */ Aggressive = 'aggressive', - /** Includes an option to add a LINE Official Account as a friend in the consent screen. */ + /** Adds an inline "Add friend" option inside the consent screen. */ Normal = 'normal', } -export enum LoginPermission { +/** OAuth scopes that can be requested during login. */ +export enum Scope { + /** Grants access to the user's email address. Requires channel approval from LINE. */ Email = 'email', - /** To get an ID token in the login response. */ + /** Issues an OpenID Connect ID token in the login response. Required to receive `idToken` and `idTokenNonce`. */ OpenId = 'openid', - /** To get user's profile including the user ID, display name, and the profile image URL in the login response. */ + /** Grants access to the user's basic profile: display name, picture URL, status message, and user ID. */ Profile = 'profile', } -export interface AccessToken { - /** The value of the access token. */ - accessToken: string - /** The amount of time until the access token expires. */ - expiresIn: string - /** The raw string of the ID token bound to the access token. Only if the access token is obtained with the `.openID` permission. */ - idToken?: string -} - -export interface FriendshipStatus { - /** Whether the LINE Official Account is a friend of the user or not. */ - friendFlag: boolean -} +// ─── Parameter types ───────────────────────────────────────────────────────── export interface SetupParams { - /** The channel ID of the LINE Login channel. */ - channelId: string - /** The universal link URL for LINE Login. */ - universalLinkUrl?: string + /** The LINE Login channel ID. */ + readonly channelId: string + /** + * Universal link URL registered for your LINE Login channel. + * @platform ios + */ + readonly universalLinkUrl?: string } export interface LoginParams { - botPrompt?: BotPrompt - onlyWebLogin?: boolean - scopes?: LoginPermission[] + /** + * Whether to skip the LINE app and go straight to the web-based login flow. + * @default false + */ + readonly onlyWebLogin?: boolean + /** + * OAuth scopes to request. Defaults to `[Scope.Profile]` when omitted. + * @default [Scope.Profile] + */ + readonly scopes?: Scope[] + /** + * Controls the bot friend-add prompt shown during login. + * Only applicable when a LINE Official Account is linked to your channel. + * @default BotPrompt.Normal + */ + readonly botPrompt?: BotPrompt } -export interface UserProfile { - /** User's display name. */ - displayName: string - /** User's profile image URL. */ - pictureUrl?: string - /** User's status message. */ - statusMessage?: string - /** User's user ID. */ - userId: string +// ─── Response types ────────────────────────────────────────────────────────── + +export interface AccessToken { + /** Bearer token used to authorize LINE API calls. */ + readonly accessToken: string + /** + * Seconds until the access token expires (OAuth standard `expires_in`). + * Schedule a proactive refresh before this value reaches zero. + */ + readonly expiresIn: number + /** + * Raw OpenID Connect ID token string. + * Present only when `Scope.OpenId` was requested. + */ + readonly idToken?: string } -export interface LoginResult { - /** The access token obtained by the login process. */ - accessToken: AccessToken - /** Indicates that the friendship status between the user and the bot changed during the login. This value is - non-`null` only if the `.botPromptNormal` or `.botPromptAggressive` are specified as part of the - `LoginManagerOption` object when the user logs in. For more information, see Linking a bot with your LINE - Login channel at https://developers.line.me/en/docs/line-login/web/link-a-bot/. */ - friendshipStatusChanged?: boolean - /** The `nonce` value when requesting ID Token during login process. Use this value as a parameter when you - verify the ID Token against the LINE server. This value is `null` if `.openID` permission is not requested. */ - idTokenNonce?: string - /** The permissions bound to the `accessToken` object by the authorization process. Scope has them separated by spaces. */ - scope: string - /** Contains the user profile including the user ID, display name, and so on. - The value exists only when the `.profile` permission is set in the authorization request. */ - userProfile?: UserProfile +export interface UserProfile { + /** The user's LINE display name. */ + readonly displayName: string + /** URL of the user's profile picture. `undefined` if the user has not set one. */ + readonly pictureUrl?: string + /** The user's LINE status message. `undefined` if the user has not set one. */ + readonly statusMessage?: string + /** The user's LINE user ID. Stable across logins for the same channel. */ + readonly userId: string } -export interface VerifyResult { - /** The channel ID bound to the access token. */ - clientId: string - /** The amount of time until the access token expires. */ - expiresIn: string - /** Valid permissions of the access token separated by spaces */ - scope: string +export interface FriendshipStatus { + /** `true` if the user has added the linked LINE Official Account as a friend. */ + readonly friendFlag: boolean } -export interface Spec extends TurboModule { +export interface LoginResult { + /** The access token issued for this login session. */ + readonly accessToken: AccessToken /** - * Gets the access token of the current user. - * @returns + * `true` if the user's friendship status with the linked LINE Official Account + * changed during this login (e.g. they added the bot as a friend). + * Only present when `BotPrompt.Normal` or `BotPrompt.Aggressive` was used. */ - getCurrentAccessToken(): Promise + readonly friendshipStatusChanged?: boolean /** - * Gets the friendship status between the LINE Official Account (which is linked to the current channel) and the user. - * @returns + * The nonce tied to the ID token. Pass this to your server when verifying the + * ID token via the LINE server. `undefined` unless `Scope.OpenId` + * was requested. + * @see https://developers.line.biz/en/docs/line-login/verify-id-token/ */ - getFriendshipStatus(): Promise + readonly idTokenNonce?: string /** - * Gets the current user profile information. - * @returns + * Space-separated list of OAuth scopes granted (e.g. `"profile openid email"`). */ - getProfile(): Promise + readonly scope: string /** - * Logs in the user. - * @param params - * @returns + * The user's LINE profile. Present only when `Scope.Profile` was + * included in the requested scopes. */ + readonly userProfile?: UserProfile +} + +export interface VerifyResult { + /** The LINE Login channel ID the access token was issued for. */ + readonly clientId: string + /** Seconds until the access token expires. */ + readonly expiresIn: number + /** Space-separated list of scopes granted to this access token. */ + readonly scope: string +} + +// ─── TurboModule spec ──────────────────────────────────────────────────────── + +/** + * Bridge interface consumed by the React Native native module system. + * Use the default export for all SDK calls rather than referencing `Spec` directly. + */ +export interface Spec extends TurboModule { + /** Initializes the LINE SDK with your channel credentials. Must be called before any other method. */ + setup(params: SetupParams): Promise + /** Starts the LINE login flow and resolves with the authenticated session. */ login(params: LoginParams): Promise - /** - * Revokes the access token of the current user. - */ + /** Revokes the current user's access token and clears the local session. */ logout(): Promise - /** - * Refreshes the access token of the current user. - * @returns - */ + /** Returns the locally cached access token without a network call. */ + getCurrentAccessToken(): Promise + /** Exchanges the current access token for a fresh one before it expires. */ refreshAccessToken(): Promise - /** - * Initializes the Line SDK. - * @param params - */ - setup(params: SetupParams): Promise - /** - * Checks whether the access token of the current user is valid. - * @returns - */ + /** Validates the current access token against the LINE server and returns its metadata. */ verifyAccessToken(): Promise + /** Fetches the current user's LINE profile. Requires `Scope.Profile`. */ + getProfile(): Promise + /** Returns the friendship status between the current user and the channel's linked LINE Official Account. */ + getFriendshipStatus(): Promise } export default TurboModuleRegistry.getEnforcing('LineLogin')