diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..88e426e7e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +# Apply to all files +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length=120 +indent_style = space +indent_size = 4 + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e936726d5..dff3a0217 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,9 +97,11 @@ dependencies { // Saved state module for ViewModel implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion") + // Compose Navigation val composeNavigationVersion = "2.7.7" implementation("androidx.navigation:navigation-compose:$composeNavigationVersion") androidTestImplementation("androidx.navigation:navigation-testing:$composeNavigationVersion") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") // Compose Tooling for Android Studio Preview val composeToolingVersion = "1.6.8" @@ -110,7 +112,11 @@ dependencies { val hiltVersion = "2.51.1" implementation("com.google.dagger:hilt-android:$hiltVersion") ksp("com.google.dagger:hilt-android-compiler:$hiltVersion") - implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + ksp("androidx.hilt:hilt-compiler:1.2.0") + + // WorkManager + implementation("androidx.hilt:hilt-work:1.2.0") + implementation("androidx.work:work-runtime-ktx:2.9.0") // Material Design implementation("com.google.android.material:material:1.12.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e24626283..54188f32c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -39,6 +40,12 @@ + + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 1691392f7..77b6a296b 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -4,11 +4,22 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.os.Bundle +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject import kotlin.reflect.typeOf @HiltAndroidApp -internal class App : Application() { +internal class App : Application(), Configuration.Provider { + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + override fun onCreate() { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } diff --git a/app/src/main/java/to/bitkit/Constants.kt b/app/src/main/java/to/bitkit/Constants.kt index 626315b9a..cddf4d845 100644 --- a/app/src/main/java/to/bitkit/Constants.kt +++ b/app/src/main/java/to/bitkit/Constants.kt @@ -7,4 +7,5 @@ internal const val _FCM = "_FCM" internal const val _LDK = "_LDK" internal const val _BDK = "_BDK" -internal val NETWORK = Network.REGTEST +internal val BDK_NETWORK = Network.REGTEST +internal val LDK_NETWORK get() = org.ldk.enums.Network.LDKNetwork_Regtest diff --git a/app/src/main/java/to/bitkit/LauncherActivity.kt b/app/src/main/java/to/bitkit/LauncherActivity.kt index f59e415e9..ee7773f0a 100644 --- a/app/src/main/java/to/bitkit/LauncherActivity.kt +++ b/app/src/main/java/to/bitkit/LauncherActivity.kt @@ -9,12 +9,15 @@ import to.bitkit.ldk.Ldk import to.bitkit.ldk.init import to.bitkit.ldk.ldkDir import to.bitkit.ui.MainActivity +import to.bitkit.ui.initNotificationChannel +import to.bitkit.ui.logFcmToken import java.io.File class LauncherActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + initNotificationChannel() + logFcmToken() warmupNode(filesDir.absolutePath) startActivity(Intent(this, MainActivity::class.java)) } diff --git a/app/src/main/java/to/bitkit/bdk/Bdk.kt b/app/src/main/java/to/bitkit/bdk/Bdk.kt index 861b8727d..774050cbf 100644 --- a/app/src/main/java/to/bitkit/bdk/Bdk.kt +++ b/app/src/main/java/to/bitkit/bdk/Bdk.kt @@ -22,7 +22,7 @@ import org.ldk.structs.Result_NoneAPIErrorZ import org.ldk.structs.Result_ThirtyTwoBytesAPIErrorZ import org.ldk.structs.UserConfig import org.ldk.util.UInt128 -import to.bitkit.NETWORK +import to.bitkit.BDK_NETWORK import to.bitkit._BDK import to.bitkit._LDK import to.bitkit.bdk.Bdk.wallet @@ -43,12 +43,12 @@ object Bdk { private fun initWallet() { val mnemonic = loadMnemonic() - val key = DescriptorSecretKey(NETWORK, Mnemonic.fromString(mnemonic), null) + val key = DescriptorSecretKey(BDK_NETWORK, Mnemonic.fromString(mnemonic), null) wallet = Wallet( - Descriptor.newBip84(key, KeychainKind.INTERNAL, NETWORK), - Descriptor.newBip84(key, KeychainKind.EXTERNAL, NETWORK), - NETWORK, + Descriptor.newBip84(key, KeychainKind.INTERNAL, BDK_NETWORK), + Descriptor.newBip84(key, KeychainKind.EXTERNAL, BDK_NETWORK), + BDK_NETWORK, DatabaseConfig.Memory, ) @@ -86,7 +86,7 @@ object Bdk { fun getLdkEntropy(): ByteArray { val mnemonic = loadMnemonic() val key = DescriptorSecretKey( - network = NETWORK, + network = BDK_NETWORK, mnemonic = Mnemonic.fromString(mnemonic), password = null, ) @@ -141,9 +141,9 @@ object Bdk { } catch (e: Throwable) { // if mnemonic doesn't exist, generate one and save it Log.d(_BDK, "No mnemonic backup, we'll create a new wallet") - val mnemonic = Mnemonic(WordCount.WORDS12) - mnemonicFile.writeText(mnemonic.asString()) - mnemonic.asString() + val mnemonic = Mnemonic(WordCount.WORDS12).asString() + mnemonicFile.writeText(mnemonic) + mnemonic } } } diff --git a/app/src/main/java/to/bitkit/data/Syncer.kt b/app/src/main/java/to/bitkit/data/Syncer.kt index 8a46c8cde..e354a3924 100644 --- a/app/src/main/java/to/bitkit/data/Syncer.kt +++ b/app/src/main/java/to/bitkit/data/Syncer.kt @@ -82,7 +82,6 @@ class LdkSyncer @Inject constructor( } } - } // Add confirmed Tx from filtered Transaction Ids diff --git a/app/src/main/java/to/bitkit/fcm/MessagingService.kt b/app/src/main/java/to/bitkit/fcm/MessagingService.kt index 0db98ddbc..8beb018fa 100644 --- a/app/src/main/java/to/bitkit/fcm/MessagingService.kt +++ b/app/src/main/java/to/bitkit/fcm/MessagingService.kt @@ -1,10 +1,23 @@ package to.bitkit.fcm +import android.content.Context import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.delay import to.bitkit._FCM +import to.bitkit.data.RestApi +import to.bitkit.data.Syncer import to.bitkit.ui.payInvoice +import to.bitkit.warmupNode import java.util.Date internal class MessagingService : FirebaseMessagingService() { @@ -33,7 +46,7 @@ internal class MessagingService : FirebaseMessagingService() { Log.d(_FCM, "\n") if (message.needsScheduling()) { - scheduleJob() + scheduleJob(message.data) } else { handleNow(message.data) } @@ -42,7 +55,7 @@ internal class MessagingService : FirebaseMessagingService() { } /** - * TODO Handle message within 10 seconds. + * Handle message within 10 seconds. */ private fun handleNow(data: Map) { val bolt11 = data["bolt11"].orEmpty() @@ -54,19 +67,67 @@ internal class MessagingService : FirebaseMessagingService() { } /** - * TODO Schedule async work using WorkManager for long-running tasks (10 seconds or more) + * Schedule async work using WorkManager for tasks of 10+ seconds. */ - private fun scheduleJob() { - TODO("Not yet implemented: scheduleJob") + private fun scheduleJob(messageData: Map) { + val work = OneTimeWorkRequest.Builder(PayWorker::class.java) + .setInputData( + Data.Builder() + .putString("bolt11", messageData["bolt11"].orEmpty()) + .build() + ) + .build() + WorkManager.getInstance(this) + .beginWith(work) + .enqueue() } private fun RemoteMessage.needsScheduling(): Boolean { - // return notification == null && data.isNotEmpty() - return false + return notification == null && + data.containsKey("bolt11") } override fun onNewToken(token: String) { this.token = token Log.d(_FCM, "onNewToken: $token") } -} \ No newline at end of file +} + +@HiltWorker +class PayWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted private val workerParams: WorkerParameters, + private val syncer: Syncer, + private val restApi: RestApi, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + Log.d(_FCM, "Node waking up from notification…") + warmupNode(appContext.filesDir.absolutePath) + .let { Log.d(_FCM, "Node wakeup result: $it…") } + + Log.d(_FCM, "Syncing BDK & LDK from notification…") + syncer.sync() + + restApi.connectPeer() + .let { Log.d(_FCM, "Connect peer from notification result: $it") } + + workerParams.inputData.getString("bolt11")?.let { bolt11 -> + delay(1500) // sleep on bg queue + val isSuccess = payInvoice(bolt11) + if (isSuccess) { + return Result.success() + } else { + return Result.failure( + Data.Builder() + .putString("reason:", "payment error") + .build() + ) + } + } + return Result.failure( + Data.Builder() + .putString("reason:", "bolt11 field missing") + .build() + ) + } +} diff --git a/app/src/main/java/to/bitkit/ldk/Ldk.kt b/app/src/main/java/to/bitkit/ldk/Ldk.kt index 10cbfe4d5..6f92074c1 100644 --- a/app/src/main/java/to/bitkit/ldk/Ldk.kt +++ b/app/src/main/java/to/bitkit/ldk/Ldk.kt @@ -3,7 +3,6 @@ package to.bitkit.ldk import android.util.Log import org.ldk.batteries.ChannelManagerConstructor import org.ldk.batteries.NioPeerHandler -import org.ldk.enums.Network import org.ldk.structs.BroadcasterInterface import org.ldk.structs.ChainMonitor import org.ldk.structs.ChannelHandshakeConfig @@ -24,6 +23,7 @@ import org.ldk.structs.Result_NetworkGraphDecodeErrorZ import org.ldk.structs.Result_ProbabilisticScorerDecodeErrorZ import org.ldk.structs.UserConfig import org.ldk.structs.WatchedOutput +import to.bitkit.LDK_NETWORK import to.bitkit._LDK import to.bitkit.bdk.Bdk import to.bitkit.data.WatchedTransaction @@ -78,7 +78,7 @@ fun Ldk.init( initNetworkGraph(logger) - val filter: Filter = Filter.new_impl(LdkFilter) + val filter = Filter.new_impl(LdkFilter) chainMonitor = ChainMonitor.of( Option_FilterZ.some(filter), txBroadcaster, @@ -104,79 +104,61 @@ fun Ldk.init( } try { - if (serializedChannelManager?.isNotEmpty() == true) { + val entropySource = keysManager.inner.as_EntropySource() + val nodeSigner = keysManager.inner.as_NodeSigner() + val signerProvider = keysManager.inner.as_SignerProvider() + val pScoringDecayParams = ProbabilisticScoringDecayParameters.with_default() + val pScoringFeeParams = ProbabilisticScoringFeeParameters.with_default() + + val constructor = if (serializedChannelManager?.isNotEmpty() == true) { // Restore from disk - val constructor = ChannelManagerConstructor( + ChannelManagerConstructor( serializedChannelManager, serializedChannelMonitors, userConfig, - keysManager.inner.as_EntropySource(), - keysManager.inner.as_NodeSigner(), - keysManager.inner.as_SignerProvider(), + entropySource, + nodeSigner, + signerProvider, feeEstimator, chainMonitor, filter, networkGraph.write(), - ProbabilisticScoringDecayParameters.with_default(), - ProbabilisticScoringFeeParameters.with_default(), + pScoringDecayParams, + pScoringFeeParams, scorer.write(), null, txBroadcaster, logger, ) - - channelManagerConstructor = constructor - channelManager = constructor.channel_manager - nioPeerHandler = constructor.nio_peer_handler - peerManager = constructor.peer_manager - networkGraph = constructor.net_graph - - constructor.chain_sync_completed( - LdkEventHandler, - true - ) - - constructor.nio_peer_handler.bind_listener( - InetSocketAddress( - "127.0.0.1", - 9777 - ) - ) - } else { // Start from scratch - val constructor = ChannelManagerConstructor( - Network.LDKNetwork_Regtest, + ChannelManagerConstructor( + LDK_NETWORK, userConfig, latestBlockHash.toByteArray(), latestBlockHeight, - keysManager.inner.as_EntropySource(), - keysManager.inner.as_NodeSigner(), - keysManager.inner.as_SignerProvider(), + entropySource, + nodeSigner, + signerProvider, feeEstimator, chainMonitor, networkGraph, - ProbabilisticScoringDecayParameters.with_default(), - ProbabilisticScoringFeeParameters.with_default(), + pScoringDecayParams, + pScoringFeeParams, null, txBroadcaster, logger, ) - - channelManagerConstructor = constructor - channelManager = constructor.channel_manager - peerManager = constructor.peer_manager - nioPeerHandler = constructor.nio_peer_handler - networkGraph = constructor.net_graph - - constructor.chain_sync_completed(LdkEventHandler, true) - constructor.nio_peer_handler.bind_listener( - InetSocketAddress( - "127.0.0.1", - 9777, - ) - ) } + channelManagerConstructor = constructor + channelManager = constructor.channel_manager + peerManager = constructor.peer_manager + nioPeerHandler = constructor.nio_peer_handler + networkGraph = constructor.net_graph + + constructor.chain_sync_completed(LdkEventHandler, true) + nioPeerHandler.bind_listener(InetSocketAddress("127.0.0.1", 9777)) + return true } catch (e: Exception) { Log.d(_LDK, "Error starting LDK:\n" + e.message) @@ -185,56 +167,52 @@ fun Ldk.init( } private fun initKeysManager(entropy: ByteArray) { - val startTimeSecs = System.currentTimeMillis() / 1000 - val startTimeNano = (System.currentTimeMillis() * 1000).toInt() + val timeMillis = System.currentTimeMillis() + val startTimeSecs = timeMillis / 1000 + val startTimeNano = (timeMillis * 1000).toInt() Ldk.keysManager = LdkKeysManager( entropy, startTimeSecs, startTimeNano, - Bdk.wallet + Bdk.wallet, ) } private fun initNetworkGraph(logger: Logger) { - val graphFile = File(ldkDir + "/" + "network-graph.bin") + val graphFile = File("$ldkDir/network-graph.bin") if (graphFile.exists()) { - Log.d(_LDK, "Network graph found and loaded from disk.") - (NetworkGraph.read( - graphFile.readBytes(), logger, - ) as? Result_NetworkGraphDecodeErrorZ.Result_NetworkGraphDecodeErrorZ_OK)?.let { res -> - Ldk.networkGraph = res.res + when (val graph = NetworkGraph.read(graphFile.readBytes(), logger)) { + is Result_NetworkGraphDecodeErrorZ.Result_NetworkGraphDecodeErrorZ_OK -> { + Ldk.networkGraph = graph.res + } } + Log.d(_LDK, "Network graph found and loaded from disk.") } else { - Log.d(_LDK, "Network graph not found on disk, syncing from scratch.") - Ldk.networkGraph = NetworkGraph.of(Network.LDKNetwork_Regtest, logger) + Ldk.networkGraph = NetworkGraph.of(LDK_NETWORK, logger) + Log.d(_LDK, "Network graph not found on disk, synced from scratch.") } } private fun initProbabilisticScorer(logger: Logger) { + val decayParams = ProbabilisticScoringDecayParameters.with_default() val scorerFile = File("$ldkDir/scorer.bin") - if (scorerFile.exists()) { - val scorerReaderResult = ProbabilisticScorer.read( - scorerFile.readBytes(), ProbabilisticScoringDecayParameters.with_default(), - Ldk.networkGraph, logger - ) - if (scorerReaderResult.is_ok) { - val probabilisticScorer = - (scorerReaderResult as Result_ProbabilisticScorerDecodeErrorZ.Result_ProbabilisticScorerDecodeErrorZ_OK).res - Ldk.scorer = MultiThreadedLockableScore.of(probabilisticScorer.as_Score()) - Log.d(_LDK, "Probabilistic Scorer found and loaded from on disk.") + val scorer = if (scorerFile.exists()) { + val read = ProbabilisticScorer + .read(scorerFile.readBytes(), decayParams, Ldk.networkGraph, logger) + if (read.is_ok) { + check(read is Result_ProbabilisticScorerDecodeErrorZ.Result_ProbabilisticScorerDecodeErrorZ_OK) + Log.d(_LDK, "Probabilistic Scorer found and loaded from disk.") + // return: + read.res } else { - Log.d(_LDK, "Error loading Probabilistic Scorer.") - val decayParams = ProbabilisticScoringDecayParameters.with_default() - val probabilisticScorer = ProbabilisticScorer.of( - decayParams, - Ldk.networkGraph, logger - ) - Ldk.scorer = MultiThreadedLockableScore.of(probabilisticScorer.as_Score()) - Log.d(_LDK, "Probabilistic Scorer not found on disk, started from scratch.") + Log.d(_LDK, "Error loading Probabilistic Scorer, started from scratch.") + // return: + ProbabilisticScorer.of(decayParams, Ldk.networkGraph, logger) } } else { - val decayParams = ProbabilisticScoringDecayParameters.with_default() - val probabilisticScorer = ProbabilisticScorer.of(decayParams, Ldk.networkGraph, logger) - Ldk.scorer = MultiThreadedLockableScore.of(probabilisticScorer.as_Score()) + Log.d(_LDK, "Probabilistic Scorer not found on disk, started from scratch.") + // return: + ProbabilisticScorer.of(decayParams, Ldk.networkGraph, logger) } + Ldk.scorer = MultiThreadedLockableScore.of(scorer.as_Score()) } diff --git a/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt b/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt index 227cc57dc..ce39ff52e 100644 --- a/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt +++ b/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt @@ -19,7 +19,7 @@ import kotlin.reflect.typeOf object LdkEventHandler : ChannelManagerConstructor.EventHandler { override fun handle_event(event: Event) { - Log.d(_LDK, "LdkEventHandler: handle_event") + Log.d(_LDK, "LdkEventHandler: handle_event: $event") handleEvent(event) } @@ -47,174 +47,165 @@ object LdkEventHandler : ChannelManagerConstructor.EventHandler { @OptIn(ExperimentalUnsignedTypes::class) private fun handleEvent(event: Event) { - if (event is Event.FundingGenerationReady) { - Log.d(_LDK, "event: FundingGenerationReady") - if (event.output_script.size == 34 && - event.output_script[0].toInt() == 0 && - event.output_script[1].toInt() == 32 - ) { - val rawTx = Bdk.buildFundingTx(event.channel_value_satoshis, event.output_script) - try { - val fundingTx = channelManager.funding_transaction_generated( - event.temporary_channel_id, - event.counterparty_node_id, - rawTx.serialize().toUByteArray().toByteArray() - ) - when (fundingTx) { - is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_OK -> { - Log.d(_LDK, "Funding tx generated") - } - - is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_Err -> { - Log.d(_LDK, "Funding tx error: ${fundingTx.err}") + when (event) { + is Event.FundingGenerationReady -> { + Log.d(_LDK, "event: FundingGenerationReady") + if (event.output_script.size == 34 && + event.output_script[0].toInt() == 0 && + event.output_script[1].toInt() == 32 + ) { + val rawTx = Bdk.buildFundingTx(event.channel_value_satoshis, event.output_script) + try { + val fundingTx = channelManager.funding_transaction_generated( + event.temporary_channel_id, + event.counterparty_node_id, + rawTx.serialize().toUByteArray().toByteArray() + ) + when (fundingTx) { + is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_OK -> + Log.d(_LDK, "Funding tx generated") + + is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_Err -> + Log.d(_LDK, "Funding tx error: ${fundingTx.err}") } + } catch (e: Exception) { + Log.d(_LDK, "FundingGenerationReady error: ${e.message}") } - } catch (e: Exception) { - Log.d(_LDK, "FundingGenerationReady error: ${e.message}") } - } - } - if (event is Event.OpenChannelRequest) { - Log.d(_LDK, "event: OpenChannelRequest") - - val json = JsonBuilder() - .put("counterparty_node_id", event.counterparty_node_id.toHex()) - .put("temporary_channel_id", event.temporary_channel_id.toHex()) - .put("push_sat", (event.push_msat.toInt() / 1000).toString()) - .put("funding_satoshis", event.funding_satoshis.toString()) - .put("channel_type", event.channel_type.toString()) - - json.persist("$ldkDir/events_open_channel_request") - Ldk.Events.fundingGenerationReady += json.toString() - - Ldk.Channel.temporaryId = event.temporary_channel_id - Ldk.Channel.counterpartyNodeId = event.counterparty_node_id - - val userChannelId = UInt128(Random.nextLong(0, 100)) - val res = channelManager.accept_inbound_channel( - event.temporary_channel_id, - event.counterparty_node_id, - userChannelId, - ) - - res?.let { - if (it.is_ok) { - Log.d(_LDK, "OpenChannelRequest accepted") - } else { - Log.d(_LDK, "OpenChannelRequest rejected") + is Event.OpenChannelRequest -> { + Log.d(_LDK, "event: OpenChannelRequest") + val json = JsonBuilder() + .put("counterparty_node_id", event.counterparty_node_id.toHex()) + .put("temporary_channel_id", event.temporary_channel_id.toHex()) + .put("push_sat", (event.push_msat.toInt() / 1000).toString()) + .put("funding_satoshis", event.funding_satoshis.toString()) + .put("channel_type", event.channel_type.toString()) + json.persist("$ldkDir/events_open_channel_request") + Ldk.Events.fundingGenerationReady += json.toString() + Ldk.Channel.temporaryId = event.temporary_channel_id + Ldk.Channel.counterpartyNodeId = event.counterparty_node_id + val userChannelId = UInt128(Random.nextLong(0, 100)) + val res = channelManager.accept_inbound_channel( + event.temporary_channel_id, + event.counterparty_node_id, + userChannelId, + ) + res?.let { + if (it.is_ok) { + Log.d(_LDK, "OpenChannelRequest accepted") + } else { + Log.d(_LDK, "OpenChannelRequest rejected") + } } } - } - if (event is Event.ChannelClosed) { - Log.d(_LDK, "event: ChannelClosed") - val json = JsonBuilder() - .put("channel_id", event.channel_id.toHex()) - .put("user_channel_id", event.user_channel_id.toString()) + is Event.ChannelClosed -> { + Log.d(_LDK, "event: ChannelClosed") + val json = JsonBuilder() + .put("channel_id", event.channel_id.toHex()) + .put("user_channel_id", event.user_channel_id.toString()) - val reason = event.reason - if (reason is ClosureReason.CommitmentTxConfirmed) { - json.put("reason", "CommitmentTxConfirmed") - } - if (reason is ClosureReason.CooperativeClosure) { - json.put("reason", "CooperativeClosure") - } - if (reason is ClosureReason.CounterpartyForceClosed) { - json.put("reason", "CounterpartyForceClosed") - json.put("text", reason.peer_msg.toString()) - } - if (reason is ClosureReason.DisconnectedPeer) { - json.put("reason", typeOf().toString()) - } - if (reason is ClosureReason.HolderForceClosed) { - json.put("reason", "HolderForceClosed") - } - if (reason is ClosureReason.OutdatedChannelManager) { - json.put("reason", "OutdatedChannelManager") - } - if (reason is ClosureReason.ProcessingError) { - json.put("reason", "ProcessingError") - json.put("text", reason.err) + val reason = event.reason + if (reason is ClosureReason.CommitmentTxConfirmed) { + json.put("reason", "CommitmentTxConfirmed") + } + if (reason is ClosureReason.CooperativeClosure) { + json.put("reason", "CooperativeClosure") + } + if (reason is ClosureReason.CounterpartyForceClosed) { + json.put("reason", "CounterpartyForceClosed") + json.put("text", reason.peer_msg.toString()) + } + if (reason is ClosureReason.DisconnectedPeer) { + json.put("reason", typeOf().toString()) + } + if (reason is ClosureReason.HolderForceClosed) { + json.put("reason", "HolderForceClosed") + } + if (reason is ClosureReason.OutdatedChannelManager) { + json.put("reason", "OutdatedChannelManager") + } + if (reason is ClosureReason.ProcessingError) { + json.put("reason", "ProcessingError") + json.put("text", reason.err) + } + json.persist("$ldkDir/events_channel_closed") + Ldk.Events.channelClosed += json.toString() } - json.persist("$ldkDir/events_channel_closed") - Ldk.Events.channelClosed += json.toString() - } - if (event is Event.ChannelPending) { - Log.d(_LDK, "event: ChannelPending") - val json = JsonBuilder() - .put("channel_id", event.channel_id.toHex()) - .put("tx_id", event.funding_txo._txid.toHex()) - .put("user_channel_id", event.user_channel_id.toString()) - json.persist("$ldkDir/events_channel_pending") - } - - if (event is Event.ChannelReady) { - Log.d(_LDK, "event: ChannelReady") - val json = JsonBuilder() - .put("channel_id", event.channel_id.toHex()) - .put("user_channel_id", event.user_channel_id.toString()) - json.persist("$ldkDir/events_channel_ready") - } + is Event.ChannelPending -> { + Log.d(_LDK, "event: ChannelPending") + val json = JsonBuilder() + .put("channel_id", event.channel_id.toHex()) + .put("tx_id", event.funding_txo._txid.toHex()) + .put("user_channel_id", event.user_channel_id.toString()) + json.persist("$ldkDir/events_channel_pending") + } - if (event is Event.PaymentSent) { - Log.d(_LDK, "event: PaymentSent") - } + is Event.ChannelReady -> { + Log.d(_LDK, "event: ChannelReady") + val json = JsonBuilder() + .put("channel_id", event.channel_id.toHex()) + .put("user_channel_id", event.user_channel_id.toString()) + json.persist("$ldkDir/events_channel_ready") + } - if (event is Event.PaymentFailed) { - Log.d(_LDK, "event: PaymentFailed") - } + is Event.PaymentSent -> { + Log.d(_LDK, "event: PaymentSent") + } - if (event is Event.PaymentPathFailed) { - Log.d(_LDK, "event: PaymentPathFailed${event.failure}") - } + is Event.PaymentFailed -> { + Log.d(_LDK, "event: PaymentFailed") + } - if (event is Event.PendingHTLCsForwardable) { - Log.d(_LDK, "event: PendingHTLCsForwardable") - channelManager.process_pending_htlc_forwards() - } + is Event.PaymentPathFailed -> { + Log.d(_LDK, "event: PaymentPathFailed: ${event.failure}") + } - if (event is Event.SpendableOutputs) { - Log.d(_LDK, "event: SpendableOutputs") - val outputs = event.outputs - try { - val address = newAddress() - val script = Address(address).scriptPubkey().toBytes().toUByteArray().toByteArray() - val txOut: Array = arrayOf() - val res = Ldk.keysManager.inner.spend_spendable_outputs( - outputs, - txOut, - script, - 1000, - null - ) + is Event.PendingHTLCsForwardable -> { + Log.d(_LDK, "event: PendingHTLCsForwardable") + channelManager.process_pending_htlc_forwards() + } - if (res != null) { - if (res.is_ok) { - val tx = (res as Result_TransactionNoneZ.Result_TransactionNoneZ_OK).res - val txs: Array = arrayOf() - txs.plus(tx) + is Event.SpendableOutputs -> { + Log.d(_LDK, "event: SpendableOutputs") + val outputs = event.outputs + try { + val address = newAddress() + val script = Address(address).scriptPubkey().toBytes().toUByteArray().toByteArray() + val txOut: Array = arrayOf() + val res = Ldk.keysManager.inner.spend_spendable_outputs( + outputs, + txOut, + script, + 1000, + null + ) + if (res != null) { + if (res.is_ok) { + val tx = (res as Result_TransactionNoneZ.Result_TransactionNoneZ_OK).res + val txs: Array = arrayOf() + txs.plus(tx) - LdkBroadcaster.broadcast_transactions(txs) + LdkBroadcaster.broadcast_transactions(txs) + } } + } catch (e: Exception) { + Log.d(_LDK, "PaymentClaimable Error: ${e.message}") } - - } catch (e: Exception) { - Log.d(_LDK, "PaymentClaimable Error: ${e.message}") } - } - - if (event is Event.PaymentClaimable) { - Log.d(_LDK, "event: PaymentClaimable") - if (event.payment_hash != null) { - channelManager.claim_funds(event.payment_hash) + is Event.PaymentClaimable -> { + Log.d(_LDK, "event: PaymentClaimable") + if (event.payment_hash != null) { + channelManager.claim_funds(event.payment_hash) + } } - } - if (event is Event.PaymentClaimed) { - Log.d(_LDK, "event ClaimedPayment: ${event.payment_hash}") + is Event.PaymentClaimed -> { + Log.d(_LDK, "event ClaimedPayment: ${event.payment_hash}") + } } } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 5ccd3bb41..9c6cac3c7 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -2,7 +2,6 @@ package to.bitkit.ui import android.os.Build import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent @@ -26,7 +25,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -42,11 +40,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.google.android.gms.tasks.OnCompleteListener -import com.google.firebase.messaging.FirebaseMessaging import dagger.hilt.android.AndroidEntryPoint import to.bitkit.R -import to.bitkit._FCM import to.bitkit.ext.requiresPermission import to.bitkit.ext.toast import to.bitkit.ui.theme.AppThemeSurface @@ -57,20 +52,6 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - initNotificationChannel() - - // Logs FCM registration token - fun logFcmToken() { - FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> - if (!task.isSuccessful) { - Log.w(_FCM, "FCM registration token failed", task.exception) - return@OnCompleteListener - } - val token = task.result - Log.d(_FCM, "FCM registration token: $token") - }) - } - logFcmToken() setContent { enableEdgeToEdge() @@ -105,8 +86,8 @@ class MainActivity : ComponentActivity() { title = "Bitkit Notification", text = "Short custom notification description", bigText = "Much longer text that cannot fit one line " + - "because the lightning channel has been updated " + - "via a push notification bro…", + "because the lightning channel has been updated " + + "via a push notification bro…", ) } Unit @@ -135,76 +116,74 @@ private fun MainScreen( viewModel: MainViewModel = hiltViewModel(), startContent: @Composable () -> Unit = {}, ) { - Surface { - val navController = rememberNavController() - val currentBackStackEntry by navController.currentBackStackEntryAsState() - val isBackButtonVisible by remember(currentBackStackEntry) { - derivedStateOf { - navController.previousBackStackEntry?.destination?.route == Routes.Settings.destination - } + val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val isBackButtonVisible by remember(currentBackStackEntry) { + derivedStateOf { + navController.previousBackStackEntry?.destination?.route == Routes.Settings.destination } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - navigationIcon = { - if (isBackButtonVisible) { - IconButton(onClick = navController::popBackStack) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(24.dp) - ) - } - } - }, - title = { - Text(stringResource(R.string.app_name)) - }, - actions = { - IconButton(viewModel::sync) { + } + Scaffold( + topBar = { + CenterAlignedTopAppBar( + navigationIcon = { + if (isBackButtonVisible) { + IconButton(onClick = navController::popBackStack) { Icon( - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.sync), - modifier = Modifier, + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.size(24.dp) ) } } - ) - }, - bottomBar = { - NavigationBar(tonalElevation = 5.dp) { - var selected by remember { mutableIntStateOf(0) } - navItems.forEachIndexed { i, it -> - NavigationBarItem( - icon = { - val icon = if (selected != i) it.icon.first else it.icon.second - Icon( - imageVector = icon, - contentDescription = stringResource(it.title), - ) - }, - label = { Text(stringResource(it.title)) }, - selected = selected == i, - onClick = { - selected = i - navController.navigate(it.route.destination) { - navController.graph.startDestinationRoute?.let { popUpTo(it) } - launchSingleTop = true - } - }, + }, + title = { + Text(stringResource(R.string.app_name)) + }, + actions = { + IconButton(viewModel::sync) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.sync), + modifier = Modifier, ) } } - }, - ) { padding -> - Box(Modifier.padding(padding)) { - AppNavHost( - navController = navController, - viewModel = viewModel, - startDestination = Routes.Wallet.destination, - startContent = startContent, - ) + ) + }, + bottomBar = { + NavigationBar(tonalElevation = 5.dp) { + var selected by remember { mutableIntStateOf(0) } + navItems.forEachIndexed { i, it -> + NavigationBarItem( + icon = { + val icon = if (selected != i) it.icon.first else it.icon.second + Icon( + imageVector = icon, + contentDescription = stringResource(it.title), + ) + }, + label = { Text(stringResource(it.title)) }, + selected = selected == i, + onClick = { + selected = i + navController.navigate(it.route.destination) { + navController.graph.startDestinationRoute?.let { popUpTo(it) } + launchSingleTop = true + } + }, + ) + } } + }, + ) { padding -> + Box(Modifier.padding(padding)) { + AppNavHost( + navController = navController, + viewModel = viewModel, + startDestination = Routes.Wallet.destination, + startContent = startContent, + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/MainViewModel.kt b/app/src/main/java/to/bitkit/ui/MainViewModel.kt index d19fe76c0..3b28da274 100644 --- a/app/src/main/java/to/bitkit/ui/MainViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/MainViewModel.kt @@ -161,7 +161,7 @@ internal fun ldkLocalBalance(): String { return localBalance.toString() } -internal fun payInvoice(bolt11Invoice: String) { +internal fun payInvoice(bolt11Invoice: String): Boolean { Log.d(_LDK, "Paying invoice: $bolt11Invoice") val invoice = decodeInvoice(bolt11Invoice) @@ -173,34 +173,30 @@ internal fun payInvoice(bolt11Invoice: String) { ) if (res.is_ok) { Log.d(_LDK, "Payment successful") + return true } val error = res as? Result_ThirtyTwoBytesPaymentErrorZ.Result_ThirtyTwoBytesPaymentErrorZ_Err val invoiceError = error?.err as? PaymentError.Invoice if (invoiceError != null) { Log.d(_LDK, "Payment failed: $invoiceError") + return true } - val sendingError = error?.err as? PaymentError.Sending - if (sendingError != null) { - when (val failure = sendingError.sending) { - RetryableSendFailure.LDKRetryableSendFailure_DuplicatePayment -> { - Log.d(_LDK, "Payment failed: DuplicatePayment") - } + when (val failure = (error?.err as? PaymentError.Sending)?.sending) { + RetryableSendFailure.LDKRetryableSendFailure_DuplicatePayment -> + Log.e(_LDK, "Payment failed: DuplicatePayment") - RetryableSendFailure.LDKRetryableSendFailure_PaymentExpired -> { - Log.d(_LDK, "Payment failed: PaymentExpired") - } + RetryableSendFailure.LDKRetryableSendFailure_PaymentExpired -> + Log.e(_LDK, "Payment failed: PaymentExpired") - RetryableSendFailure.LDKRetryableSendFailure_RouteNotFound -> { - Log.d(_LDK, "Payment failed: RouteNotFound") - } + RetryableSendFailure.LDKRetryableSendFailure_RouteNotFound -> + Log.e(_LDK, "Payment failed: RouteNotFound") - else -> { - Log.d(_LDK, "Payment failed with unknown error: $failure") - } - } + else -> + Log.e(_LDK, "Payment failed with unknown error: $failure") } + return false } internal fun decodeInvoice(bolt11Invoice: String): Bolt11Invoice? { diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index b5f4dcfab..f7caf57cb 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -9,8 +9,12 @@ import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.content.Context import android.content.Intent +import android.util.Log import androidx.core.app.NotificationCompat +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging import to.bitkit.R +import to.bitkit._FCM import to.bitkit.currentActivity import to.bitkit.ext.notificationManager import to.bitkit.ext.notificationManagerCompat @@ -76,4 +80,15 @@ internal fun Activity.pushNotification( .setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) notificationManagerCompat.notify(id, builder.build()) -} \ No newline at end of file +} + +fun logFcmToken() { + FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w(_FCM, "FCM registration token error:\n", task.exception) + return@OnCompleteListener + } + val token = task.result + Log.d(_FCM, "FCM registration token: $token") + }) +} diff --git a/tools/fcm-tester/.gitignore b/tools/fcm-tester/.gitignore new file mode 100644 index 000000000..36aefaa23 --- /dev/null +++ b/tools/fcm-tester/.gitignore @@ -0,0 +1,5 @@ +node_modules + +# Secrets +google-services.json +service-account.json diff --git a/tools/fcm-tester/.nvmrc b/tools/fcm-tester/.nvmrc new file mode 100644 index 000000000..1efe0ac63 --- /dev/null +++ b/tools/fcm-tester/.nvmrc @@ -0,0 +1 @@ +v20.15.1 diff --git a/tools/fcm-tester/README.md b/tools/fcm-tester/README.md new file mode 100644 index 000000000..1ef16a218 --- /dev/null +++ b/tools/fcm-tester/README.md @@ -0,0 +1,24 @@ +# FCM Push Notifications Tester +Simple app to auth with Google and test push notifications via FCM. +FCM should work both for Android via GCM, as well as Apple via APNS. + +Can be used either to grab a Bearer auth token for use in Postman, or to test predefined push notification messages by sending them to a specific device via FCM. + +## Prerequisite +1. Download from FCM/Google Cloud the service account JSON certificate into `./service-account.json`. +1. `npm i` or `yarn` + +## Usage + +### Easiest way to use it: + +1. Import `./fcm.postman_collection.json` in Postman +1. Mint a short-lived access token for [FCM HTTP v1 API](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages): + ```sh + npm start + ``` +1. Add the printed access token to your Postman environment in a variable named `bearerToken`. +1. Run the app, grab the `FCM Registration token` from logcat and add it to your Postman environment, in a variable named `deviceToken`. +1. Copy one of the sample payloads from `./messages` to the request body in Postman. +1. Run your request and check logcat for entries of the `FCM` tag. Before processing a remote message, the first loged line says `--- new FCM ---`. + - Whenever your access token expires, repeat step 1. diff --git a/tools/fcm-tester/fcm.postman_collection.json b/tools/fcm-tester/fcm.postman_collection.json new file mode 100644 index 000000000..1e797b33e --- /dev/null +++ b/tools/fcm-tester/fcm.postman_collection.json @@ -0,0 +1,52 @@ +{ + "info": { + "_postman_id": "4832eca8-8592-4e0e-95aa-a16f17be45c4", + "name": "fcm", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "2381817" + }, + "item": [ + { + "name": "messages:send", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{bearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"message\": {\n \"token\": \"{{deviceToken}}\",\n \"data\": {\n \"bolt11\": \"_paste_bolt11_invoice_over_here_\"\n }\n }\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/snbkandroid/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "snbkandroid", + "messages:send" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/tools/fcm-tester/index.js b/tools/fcm-tester/index.js new file mode 100644 index 000000000..b1b0fb3b5 --- /dev/null +++ b/tools/fcm-tester/index.js @@ -0,0 +1,169 @@ +/** + * Firebase Cloud Messaging (FCM) can be used to send messages to clients on iOS, Android and Web. + * + * This sample uses FCM to send two types of messages to clients that are subscribed to the `news` + * topic. One type of message is a simple notification message (display message). The other is + * a notification message (display notification) with platform specific customizations. For example, + * a badge is added to messages that are sent to iOS devices. + */ +import { request as _request } from "https"; +import { google } from "googleapis"; +import key from "./service-account.json" assert { type: "json" }; + +const PROJECT_ID = "snbkandroid"; +const HOST = "fcm.googleapis.com"; +const PATH_SEND = "/v1/projects/" + PROJECT_ID + "/messages:send"; +const MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"; +const SCOPES = [MESSAGING_SCOPE]; + +function getAccessToken() { + return new Promise(function (resolve, reject) { + const jwtClient = new google.auth.JWT( + key.client_email, + null, + key.private_key, + SCOPES, + null + ); + jwtClient.authorize(function (err, tokens) { + if (err) { + reject(err); + return; + } + resolve(tokens.access_token); + }); + }); +} + +/** + * Send HTTP request to FCM with given message. + * + * @param {object} fcmMessage will make up the body of the request. + */ +function sendFcmMessage(fcmMessage) { + getAccessToken().then(function (accessToken) { + const options = { + hostname: HOST, + path: PATH_SEND, + method: "POST", + // [START use_access_token] + headers: { + Authorization: "Bearer " + accessToken, + }, + // [END use_access_token] + }; + + const request = _request(options, function (resp) { + resp.setEncoding("utf8"); + resp.on("data", function (data) { + console.log("Message sent to Firebase for delivery, response:"); + console.log(data); + }); + }); + + request.on("error", function (err) { + console.log("Unable to send message to Firebase"); + console.log(err); + }); + + request.write(JSON.stringify(fcmMessage)); + request.end(); + }); +} + +/** + * Construct a JSON object that will be used to customize + * the messages sent to iOS and Android devices. + */ +function buildOverrideMessage() { + const fcmMessage = buildCommonMessage(); + const apnsOverride = { + payload: { + aps: { + badge: 1, + }, + }, + headers: { + "apns-priority": "10", + }, + }; + + const androidOverride = { + notification: { + click_action: "android.intent.action.MAIN", + }, + }; + + fcmMessage["message"]["android"] = androidOverride; + fcmMessage["message"]["apns"] = apnsOverride; + + return fcmMessage; +} + +/** + * Construct a JSON object that will be used to define the + * common parts of a notification message that will be sent + * to any app instance subscribed to the news topic. + */ +function buildCommonMessage() { + return { + message: { + topic: "news", + notification: { + title: "FCM Notification", + body: "Notification from FCM", + }, + }, + }; +} + +const actions = { + token: () => { + return new Promise(function (resolve, reject) { + getAccessToken() + .then(function (accessToken) { + console.log("\n🔐 ACCESS TOKEN:"); + console.log(accessToken); + resolve(accessToken); + }) + .catch((err) => { + console.error("Error fetching access token"); + reject(err); + }); + }); + }, + commonMessage: () => { + const commonMessage = buildCommonMessage(); + console.log( + "FCM request body for message using common notification object:" + ); + console.log(JSON.stringify(commonMessage, null, 2)); + sendFcmMessage(buildCommonMessage()); + }, + overrideMessage: () => { + const overrideMessage = buildOverrideMessage(); + console.log("FCM request body for override message:"); + console.log(JSON.stringify(overrideMessage, null, 2)); + sendFcmMessage(buildOverrideMessage()); + }, +}; + +const arg = process.argv[2]; +if (arg) { + if (arg == "token") { + actions.token(); + } else if (arg == "message-common") { + actions.commonMessage(); + } else if (arg == "message-override") { + actions.overrideMessage(); + } +} else { + console.log( + "Invalid command. Please append one of the following arguments:\n" + + "node index.js token" + + "\n" + + "node index.js message-common" + + "\n" + + "node index.js message-override" + ); +} diff --git a/tools/fcm-tester/messages/data.json b/tools/fcm-tester/messages/data.json new file mode 100644 index 000000000..4d80def31 --- /dev/null +++ b/tools/fcm-tester/messages/data.json @@ -0,0 +1,8 @@ +{ + "message": { + "token": "{{deviceToken}}", + "data": { + "key": "value" + } + } +} diff --git a/tools/fcm-tester/messages/notification.json b/tools/fcm-tester/messages/notification.json new file mode 100644 index 000000000..c4d5376e7 --- /dev/null +++ b/tools/fcm-tester/messages/notification.json @@ -0,0 +1,9 @@ +{ + "message": { + "token": "{{deviceToken}}", + "notification": { + "body": "This is an FCM notification message!", + "title": "FCM Message 2" + } + } +} diff --git a/tools/fcm-tester/package-lock.json b/tools/fcm-tester/package-lock.json new file mode 100644 index 000000000..0ae37a392 --- /dev/null +++ b/tools/fcm-tester/package-lock.json @@ -0,0 +1,546 @@ +{ + "name": "admin-sdk-google", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-sdk-google", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "googleapis": "^140.0.1" + } + }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.0.tgz", + "integrity": "sha512-DSrkyMTfAnAm4ks9Go20QGOcXEyW/NmZhvTYBU2rb4afBB393WIMQPWPEDMl/k8xqiNN9HYq2zao3oWXsdl2Tg==", + "dev": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dev": true, + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/google-auth-library": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.11.0.tgz", + "integrity": "sha512-epX3ww/mNnhl6tL45EQ/oixsY8JLEgUFoT4A5E/5iAR4esld9Kqv6IJGk7EmGuOgDvaarwF95hU2+v7Irql9lw==", + "dev": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "140.0.1", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-140.0.1.tgz", + "integrity": "sha512-ZGvBX4mQcFXO9ACnVNg6Aqy3KtBPB5zTuue43YVLxwn8HSv8jB7w+uDKoIPSoWuxGROgnj2kbng6acXncOQRNA==", + "dev": true, + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "dev": true, + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dev": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dev": true, + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dev": true, + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/qs": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.3.tgz", + "integrity": "sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "dev": true + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/tools/fcm-tester/package.json b/tools/fcm-tester/package.json new file mode 100644 index 000000000..755b6a91c --- /dev/null +++ b/tools/fcm-tester/package.json @@ -0,0 +1,17 @@ +{ + "name": "admin-sdk-google", + "version": "1.0.0", + "description": "Helper to test FCM notifications", + "type":"module", + "main": "index.js", + "scripts": { + "start": "node index.js token", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "ovitrif@proton.me", + "license": "MIT", + "devDependencies": { + "googleapis": "^140.0.1" + }, + "private": true +} diff --git a/tools/fcm-tester/src/sdk.js b/tools/fcm-tester/src/sdk.js new file mode 100644 index 000000000..64893a9d7 --- /dev/null +++ b/tools/fcm-tester/src/sdk.js @@ -0,0 +1,9 @@ +// TODO add example with firebase admin-sdk +// // npm i firebase-admin -D +// var admin = require("firebase-admin"); + +// var serviceAccount = require("./service-account.json"); + +// admin.initializeApp({ +// credential: admin.credential.cert(serviceAccount) +// });