From 31ebaad014ad6ba200ed72785a6e934909660a38 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Jul 2026 08:26:38 +0000
Subject: [PATCH 1/6] Initial plan
From f95e6508ead9de140c624cf8d58b6cc89326e5be Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Jul 2026 08:40:57 +0000
Subject: [PATCH 2/6] =?UTF-8?q?feat:=20initialize=20OpenBeam=20=E2=80=93?=
=?UTF-8?q?=20NFC=20tap-to-share=20app=20replacing=20Android=20Beam?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 14 +-
README.md | 65 +++-
app/build.gradle | 5 +-
app/src/main/AndroidManifest.xml | 70 +++-
app/src/main/java/.gitkeep | 0
.../java/net/duhow/openbeam/ShareActivity.kt | 331 ++++++++++++++++++
.../net/duhow/openbeam/ndef/NdefContent.kt | 29 ++
.../net/duhow/openbeam/ndef/NdefHelper.kt | 114 ++++++
.../java/net/duhow/openbeam/qr/QrResult.kt | 24 ++
.../java/net/duhow/openbeam/qr/QrScanner.kt | 91 +++++
.../duhow/openbeam/sharing/NfcShareHelper.kt | 143 ++++++++
.../duhow/openbeam/sharing/WifiDirectShare.kt | 208 +++++++++++
.../net/duhow/openbeam/util/SoundManager.kt | 59 ++++
app/src/main/res/drawable/bg_share_dialog.xml | 6 +
app/src/main/res/drawable/ic_launcher_fg.xml | 27 ++
app/src/main/res/drawable/ic_nfc.xml | 10 +
app/src/main/res/drawable/ic_wifi.xml | 10 +
.../main/res/layout-land/activity_share.xml | 85 +++++
app/src/main/res/layout/activity_share.xml | 85 +++++
.../res/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +
app/src/main/res/values-ca/strings.xml | 27 +-
app/src/main/res/values-es/strings.xml | 27 +-
app/src/main/res/values/colors.xml | 13 +
app/src/main/res/values/strings.xml | 27 +-
app/src/main/res/values/themes.xml | 44 +++
docs/android-beam-research.md | 141 ++++++++
docs/implementation-plan.md | 135 +++++++
docs/quick-share-research.md | 144 ++++++++
fastlane/Appfile | 2 +-
.../metadata/android/en-US/changelogs/1.txt | 5 +-
.../android/en-US/full_description.txt | 8 +-
.../android/en-US/short_description.txt | 2 +-
fastlane/metadata/android/en-US/title.txt | 2 +-
settings.gradle | 2 +-
35 files changed, 1918 insertions(+), 47 deletions(-)
delete mode 100644 app/src/main/java/.gitkeep
create mode 100644 app/src/main/java/net/duhow/openbeam/ShareActivity.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/qr/QrResult.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt
create mode 100644 app/src/main/java/net/duhow/openbeam/util/SoundManager.kt
create mode 100644 app/src/main/res/drawable/bg_share_dialog.xml
create mode 100644 app/src/main/res/drawable/ic_launcher_fg.xml
create mode 100644 app/src/main/res/drawable/ic_nfc.xml
create mode 100644 app/src/main/res/drawable/ic_wifi.xml
create mode 100644 app/src/main/res/layout-land/activity_share.xml
create mode 100644 app/src/main/res/layout/activity_share.xml
create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100644 app/src/main/res/values/colors.xml
create mode 100644 app/src/main/res/values/themes.xml
create mode 100644 docs/android-beam-research.md
create mode 100644 docs/implementation-plan.md
create mode 100644 docs/quick-share-research.md
diff --git a/AGENTS.md b/AGENTS.md
index 351e352..7c51605 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,13 +1,5 @@
# Agents Notes
-## IMPORTANT: To start
-
-This repo is now a blank template to begin with a Kotlin project.
-To facilitate the setup, there are some preloaded configurations defined, but
-you still must create the required missing files, such as MainActivity, XML.
-If you know it, change the Application name (MyApp) in `settings.gradle`, `build.gradle`, `Appfile`, XML and README.
-Once the base code is established, erase this section "To start" from `AGENTS.md`.
-
## Commits
Use Conventional Commits in PR title. Common types are feat, fix, chore, refactor, perf, ci, docs.
@@ -24,9 +16,9 @@ Check historic commits to avoid making up new scopes every time.
## IMPORTANT: Layout changes
-- `activity_main.xml` exists in **both**:
- - `app/src/main/res/layout/activity_main.xml` (portrait/default)
- - `app/src/main/res/layout-land/activity_main.xml` (landscape/horizontal)
+- `activity_share.xml` exists in **both**:
+ - `app/src/main/res/layout/activity_share.xml` (portrait/default)
+ - `app/src/main/res/layout-land/activity_share.xml` (landscape/horizontal)
- Any layout modification to this screen **must be applied in both files**.
- Treat portrait and landscape as a paired change to avoid regressions/crashes in horizontal mode.
diff --git a/README.md b/README.md
index 794b48c..b2607e7 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,19 @@
-# Android Template
+# OpenBeam
- A repository template to create new Android Kotlin apps!
+ Revive the Android Beam experience on modern Android
-
-
+
+
-
-
-
-
-
-
-
+
@@ -28,22 +21,56 @@
---
-Click the button. [](https://github.com/new?template_name=android-template&template_owner=duhow)
+OpenBeam is an open-source Android app that brings back the tap-to-share experience of **Android Beam** on modern Android (8.0+). Google removed Android Beam in Android 10 — OpenBeam fills that gap without requiring Google Play Services.
+
+## Features
+
+- **Share URLs** via NFC tap — creates an NDEF URI record on any writable NFC tag
+- **Share contacts** (vCard) via NFC tap
+- **Share images with QR codes** — scans the image for a QR code and beams the decoded content (URL or Wi-Fi credentials) via NFC
+- **Wi-Fi Direct fallback** — when the image has no QR code or NFC is unavailable, transfers the file over Wi-Fi Direct (no Google Services required)
+- **No app drawer icon** — OpenBeam only appears in the system share sheet
+- **Translations**: English, Español, Català
+
+## How it works
+
+1. Tap **Share** in any app and select **OpenBeam** from the share sheet
+2. OpenBeam shows an overlay dialog
+3. For text/URLs/vCards: tap your phone against an NFC tag to write the data
+4. For images: OpenBeam first scans for a QR code
+ - QR found → converts to NDEF message → tap to write to NFC tag
+ - No QR → transfers via Wi-Fi Direct to another nearby device running OpenBeam
-Setup. Code or ask to code. Do whatever you want.
+## Permissions
-### Keystore
+| Permission | Why |
+|---|---|
+| NFC | Core feature: write NDEF data to NFC tags |
+| ACCESS_WIFI_STATE / CHANGE_WIFI_STATE | Wi-Fi Direct peer discovery and file transfer |
+| NEARBY_WIFI_DEVICES (API 33+) | Wi-Fi peer discovery without location |
+| ACCESS_FINE_LOCATION (API < 33) | Required by Android for Wi-Fi peer discovery |
+| INTERNET | TCP socket communication over Wi-Fi Direct |
-To sign your APK, create a keystore. **Keep it safe**.
+## Building
```sh
-keytool -genkeypair -v -keystore release.jks -alias ${APP_NAME} -keyalg EC -groupname secp256r1 -sigalg SHA256withECDSA -validity 10000
+./fastlane/gradlew assembleDebug
```
-You can upload it to GitHub Actions as Secret `ANDROID_KEYSTORE_BASE64` to generate Release APKs.
+Or with Fastlane:
+```sh
+bundle exec fastlane build
+```
+
+## Signing a release
```sh
+keytool -genkeypair -v -keystore release.jks -alias openbeam -keyalg EC -groupname secp256r1 -sigalg SHA256withECDSA -validity 10000
base64 -w 0 release.jks ; echo
```
-Then define `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_PASSWORD` (default is the same), and `ANDROID_KEY_ALIAS` as configured.
+Add GitHub Secrets: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_PASSWORD`, `ANDROID_KEY_ALIAS`.
+
+## License
+
+Apache 2.0 — see [LICENSE](LICENSE) for details.
diff --git a/app/build.gradle b/app/build.gradle
index 6ec39ee..56c0272 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -26,11 +26,11 @@ def getGitVersionCode = { ->
}
android {
- namespace 'net.duhowpi.myapp'
+ namespace 'net.duhow.openbeam'
compileSdk 35
defaultConfig {
- applicationId "net.duhowpi.myapp"
+ applicationId "net.duhow.openbeam"
minSdk 26
targetSdk 35
versionCode getGitVersionCode()
@@ -71,6 +71,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
+ implementation 'com.google.zxing:core:3.5.3'
}
tasks.register('printVersionCode') {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d841ed3..56d13d0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,23 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:launchMode="singleTop"
+ android:excludeFromRecents="true"
+ android:theme="@style/Theme.OpenBeam.Dialog">
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/.gitkeep b/app/src/main/java/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/app/src/main/java/net/duhow/openbeam/ShareActivity.kt b/app/src/main/java/net/duhow/openbeam/ShareActivity.kt
new file mode 100644
index 0000000..8a08008
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/ShareActivity.kt
@@ -0,0 +1,331 @@
+package net.duhow.openbeam
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.nfc.NdefMessage
+import android.nfc.NfcAdapter
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import net.duhow.openbeam.ndef.NdefContent
+import net.duhow.openbeam.ndef.NdefHelper
+import net.duhow.openbeam.qr.QrResult
+import net.duhow.openbeam.qr.QrScanner
+import net.duhow.openbeam.sharing.NfcShareHelper
+import net.duhow.openbeam.sharing.NfcShareState
+import net.duhow.openbeam.sharing.WifiDirectShare
+import net.duhow.openbeam.sharing.WifiShareState
+import net.duhow.openbeam.util.SoundManager
+
+/**
+ * Transparent overlay activity that handles all incoming share intents.
+ *
+ * The activity is NOT exported with a LAUNCHER intent filter – it only appears
+ * in the Android share sheet via ACTION_SEND. After sharing completes (or the
+ * user cancels), the activity finishes immediately.
+ *
+ * Share flow:
+ * • text/plain, text/vcard → NDEF message → NFC tag write
+ * • image/* → QR scan → NDEF (if QR found)
+ * → Wi-Fi Direct (if no QR / NFC unavailable)
+ */
+class ShareActivity : AppCompatActivity() {
+
+ private lateinit var nfcHelper: NfcShareHelper
+ private lateinit var wifiShare: WifiDirectShare
+
+ private lateinit var tvStatus: TextView
+ private lateinit var tvHint: TextView
+ private lateinit var progressBar: ProgressBar
+ private lateinit var ivIcon: ImageView
+ private lateinit var btnCancel: Button
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_share)
+
+ tvStatus = findViewById(R.id.tv_status)
+ tvHint = findViewById(R.id.tv_hint)
+ progressBar = findViewById(R.id.progress_bar)
+ ivIcon = findViewById(R.id.iv_icon)
+ btnCancel = findViewById(R.id.btn_cancel)
+
+ nfcHelper = NfcShareHelper(this)
+ wifiShare = WifiDirectShare(this)
+
+ btnCancel.setOnClickListener {
+ SoundManager.playTap(this)
+ finish()
+ }
+
+ handleShareIntent(intent)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ wifiShare.register()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ nfcHelper.stopSharing()
+ wifiShare.unregister()
+ }
+
+ /** Forward NFC intents (foreground dispatch) to the NFC helper. */
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ if (intent.action == NfcAdapter.ACTION_TAG_DISCOVERED ||
+ intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED ||
+ intent.action == NfcAdapter.ACTION_TECH_DISCOVERED
+ ) {
+ SoundManager.playBeam(this)
+ nfcHelper.onNewIntent(intent)
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Intent handling
+ // -------------------------------------------------------------------------
+
+ private fun handleShareIntent(intent: Intent?) {
+ if (intent?.action != Intent.ACTION_SEND) {
+ setStatus(getString(R.string.status_unsupported))
+ return
+ }
+
+ val mimeType = intent.type ?: ""
+
+ when {
+ mimeType == "text/plain" -> handleTextIntent(intent)
+ mimeType.startsWith("text/vcard") || mimeType.startsWith("text/x-vcard") ->
+ handleVCardIntent(intent)
+ mimeType.startsWith("image/") -> handleImageIntent(intent)
+ else -> {
+ setStatus(getString(R.string.status_unsupported))
+ }
+ }
+ }
+
+ private fun handleTextIntent(intent: Intent) {
+ val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: run {
+ setStatus(getString(R.string.status_error))
+ return
+ }
+
+ val content: NdefContent = if (
+ text.startsWith("http://", ignoreCase = true) ||
+ text.startsWith("https://", ignoreCase = true)
+ ) {
+ NdefContent.Url(text)
+ } else {
+ NdefContent.PlainText(text)
+ }
+
+ shareViaNfc(content)
+ }
+
+ private fun handleVCardIntent(intent: Intent) {
+ val uri = getStreamUri(intent)
+ val vcard = uri?.let { readTextFromUri(it) }
+ ?: intent.getStringExtra(Intent.EXTRA_TEXT)
+ ?: run {
+ setStatus(getString(R.string.status_error))
+ return
+ }
+ shareViaNfc(NdefContent.VCard(vcard))
+ }
+
+ private fun handleImageIntent(intent: Intent) {
+ val uri = getStreamUri(intent) ?: run {
+ setStatus(getString(R.string.status_error))
+ return
+ }
+
+ setStatus(getString(R.string.status_scanning_qr))
+ progressBar.visibility = View.VISIBLE
+
+ Thread {
+ val bitmap = loadBitmap(uri)
+ val qrResult = bitmap?.let { QrScanner.scan(it) }
+
+ runOnUiThread {
+ progressBar.visibility = View.GONE
+ if (qrResult != null) {
+ handleQrResult(qrResult)
+ } else {
+ // No QR found – fall back to Wi-Fi Direct
+ shareViaWifiDirect(uri)
+ }
+ }
+ }.start()
+ }
+
+ // -------------------------------------------------------------------------
+ // NFC sharing
+ // -------------------------------------------------------------------------
+
+ private fun shareViaNfc(content: NdefContent) {
+ val message: NdefMessage = NdefHelper.fromContent(content) ?: run {
+ setStatus(getString(R.string.status_error))
+ return
+ }
+
+ ivIcon.setImageResource(R.drawable.ic_nfc)
+ setStatus(getString(R.string.status_waiting_nfc))
+ tvHint.text = getString(R.string.hint_tap_device)
+ tvHint.visibility = View.VISIBLE
+
+ nfcHelper.startSharing(message) { state ->
+ runOnUiThread { onNfcState(state) }
+ }
+ }
+
+ private fun onNfcState(state: NfcShareState) {
+ when (state) {
+ NfcShareState.Waiting -> {
+ setStatus(getString(R.string.status_waiting_nfc))
+ }
+ NfcShareState.Writing -> {
+ setStatus(getString(R.string.status_writing))
+ progressBar.visibility = View.VISIBLE
+ }
+ NfcShareState.Success -> {
+ progressBar.visibility = View.GONE
+ setStatus(getString(R.string.status_done))
+ SoundManager.playSuccess(this)
+ finishAfterDelay()
+ }
+ NfcShareState.Error -> {
+ progressBar.visibility = View.GONE
+ setStatus(getString(R.string.status_error))
+ SoundManager.playError(this)
+ }
+ NfcShareState.Unsupported -> {
+ setStatus(getString(R.string.status_nfc_unavailable))
+ tvHint.text = getString(R.string.hint_nfc_unavailable)
+ tvHint.visibility = View.VISIBLE
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // QR handling
+ // -------------------------------------------------------------------------
+
+ private fun handleQrResult(result: QrResult) {
+ val content: NdefContent = when (result) {
+ is QrResult.Url -> NdefContent.Url(result.url)
+ is QrResult.WifiCredentials -> NdefContent.WifiCredentials(
+ result.ssid, result.password, result.authType,
+ )
+ is QrResult.RawText -> NdefContent.PlainText(result.text)
+ }
+ shareViaNfc(content)
+ }
+
+ // -------------------------------------------------------------------------
+ // Wi-Fi Direct sharing (fallback)
+ // -------------------------------------------------------------------------
+
+ private fun shareViaWifiDirect(uri: Uri) {
+ ivIcon.setImageResource(R.drawable.ic_wifi)
+ setStatus(getString(R.string.status_discovering_peers))
+ tvHint.text = getString(R.string.hint_wifi_direct)
+ tvHint.visibility = View.VISIBLE
+
+ wifiShare.discoverPeers { state ->
+ runOnUiThread { onWifiState(state, uri) }
+ }
+ }
+
+ private fun onWifiState(state: WifiShareState, fileUri: Uri) {
+ when (state) {
+ WifiShareState.Discovering -> {
+ setStatus(getString(R.string.status_discovering_peers))
+ progressBar.visibility = View.VISIBLE
+ }
+ is WifiShareState.PeersDiscovered -> {
+ progressBar.visibility = View.GONE
+ if (state.peers.isEmpty()) {
+ setStatus(getString(R.string.status_no_peers))
+ } else {
+ // Auto-connect to first peer for simplicity in this iteration
+ setStatus(getString(R.string.status_connecting))
+ wifiShare.connectToPeer(state.peers.first()) { s ->
+ runOnUiThread { onWifiState(s, fileUri) }
+ }
+ }
+ }
+ WifiShareState.Connecting -> {
+ setStatus(getString(R.string.status_connecting))
+ progressBar.visibility = View.VISIBLE
+ }
+ is WifiShareState.Connected -> {
+ setStatus(getString(R.string.status_transferring))
+ wifiShare.sendFile(fileUri, state.groupOwnerAddress) { s ->
+ runOnUiThread { onWifiState(s, fileUri) }
+ }
+ }
+ WifiShareState.Transferring -> {
+ setStatus(getString(R.string.status_transferring))
+ progressBar.visibility = View.VISIBLE
+ }
+ WifiShareState.Done -> {
+ progressBar.visibility = View.GONE
+ setStatus(getString(R.string.status_done))
+ SoundManager.playSuccess(this)
+ finishAfterDelay()
+ }
+ WifiShareState.Unavailable -> {
+ progressBar.visibility = View.GONE
+ setStatus(getString(R.string.status_wifi_unavailable))
+ SoundManager.playError(this)
+ }
+ is WifiShareState.Error -> {
+ progressBar.visibility = View.GONE
+ setStatus(getString(R.string.status_error))
+ SoundManager.playError(this)
+ }
+ WifiShareState.Receiving -> { /* not used in send flow */ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Utilities
+ // -------------------------------------------------------------------------
+
+ private fun setStatus(text: String) {
+ tvStatus.text = text
+ }
+
+ private fun finishAfterDelay(delayMs: Long = 1500L) {
+ tvStatus.postDelayed({ finish() }, delayMs)
+ }
+
+ private fun getStreamUri(intent: Intent): Uri? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
+ }
+ }
+
+ private fun readTextFromUri(uri: Uri): String? = runCatching {
+ contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
+ }.getOrNull()
+
+ private fun loadBitmap(uri: Uri): Bitmap? = runCatching {
+ contentResolver.openInputStream(uri)?.use { stream ->
+ BitmapFactory.decodeStream(stream)
+ }
+ }.getOrNull()
+}
diff --git a/app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt b/app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt
new file mode 100644
index 0000000..a8469e3
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt
@@ -0,0 +1,29 @@
+package net.duhow.openbeam.ndef
+
+/**
+ * Sealed class hierarchy representing all content types that OpenBeam can share via NDEF.
+ * Add new subclasses here to support additional content types in the future.
+ */
+sealed class NdefContent {
+
+ /** A URL (https, http, etc.) */
+ data class Url(val url: String) : NdefContent()
+
+ /** A vCard contact record (text/vcard or text/x-vcard format) */
+ data class VCard(val vcard: String) : NdefContent()
+
+ /** Plain text */
+ data class PlainText(val text: String) : NdefContent()
+
+ /**
+ * Wi-Fi credentials (parsed from a Wi-Fi QR code).
+ * @param ssid Network name
+ * @param password Network password (empty for open networks)
+ * @param authType "WPA", "WPA2", "WEP", or "" for open
+ */
+ data class WifiCredentials(
+ val ssid: String,
+ val password: String,
+ val authType: String,
+ ) : NdefContent()
+}
diff --git a/app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt b/app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt
new file mode 100644
index 0000000..81be8cc
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt
@@ -0,0 +1,114 @@
+package net.duhow.openbeam.ndef
+
+import android.nfc.NdefMessage
+import android.nfc.NdefRecord
+import android.net.Uri
+
+/**
+ * Creates [NdefMessage] objects from [NdefContent] instances.
+ * All methods return null if the content cannot be encoded.
+ */
+object NdefHelper {
+
+ /** Convert any [NdefContent] to an [NdefMessage]. Returns null on unsupported/invalid input. */
+ fun fromContent(content: NdefContent): NdefMessage? = when (content) {
+ is NdefContent.Url -> fromUrl(content.url)
+ is NdefContent.VCard -> fromVCard(content.vcard)
+ is NdefContent.PlainText -> fromText(content.text)
+ is NdefContent.WifiCredentials -> fromWifiCredentials(
+ content.ssid, content.password, content.authType,
+ )
+ }
+
+ /** Encode a URL as an NFC URI record (RTD_URI). */
+ fun fromUrl(url: String): NdefMessage? = runCatching {
+ val record = NdefRecord.createUri(Uri.parse(url))
+ NdefMessage(record)
+ }.getOrNull()
+
+ /** Encode a vCard string as a MIME record (text/vcard). */
+ fun fromVCard(vcard: String): NdefMessage? = runCatching {
+ val bytes = vcard.toByteArray(Charsets.UTF_8)
+ val record = NdefRecord.createMime("text/vcard", bytes)
+ NdefMessage(record)
+ }.getOrNull()
+
+ /** Encode plain text as an NFC Text record (RTD_TEXT, language = "en"). */
+ fun fromText(text: String, languageCode: String = "en"): NdefMessage? = runCatching {
+ val record = NdefRecord.createTextRecord(languageCode, text)
+ NdefMessage(record)
+ }.getOrNull()
+
+ /**
+ * Encode Wi-Fi credentials as a Wi-Fi Alliance WSC MIME record
+ * (type "application/vnd.wfa.wsc").
+ *
+ * The payload uses the TLV format specified by the Wi-Fi Alliance
+ * Wi-Fi Protected Setup (WPS) specification v2.0, section 12.
+ *
+ * Key TLV attribute IDs:
+ * 0x1045 – SSID
+ * 0x1003 – Authentication Type (0x0020 = WPA2-Personal)
+ * 0x100F – Encryption Type (0x0008 = AES)
+ * 0x1027 – Network Key (password)
+ */
+ fun fromWifiCredentials(ssid: String, password: String, authType: String): NdefMessage? =
+ runCatching {
+ val payload = buildWfaWscPayload(ssid, password, authType)
+ val record = NdefRecord.createMime("application/vnd.wfa.wsc", payload)
+ NdefMessage(record)
+ }.getOrNull()
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private fun buildWfaWscPayload(ssid: String, password: String, authType: String): ByteArray {
+ val ssidBytes = ssid.toByteArray(Charsets.UTF_8)
+ val passBytes = password.toByteArray(Charsets.UTF_8)
+
+ val (authTypeValue, encType) = wpaAuthAndEncTypes(authType)
+
+ return buildTlvRecord {
+ tlv(0x1045, ssidBytes)
+ tlv(0x1003, shortToBytes(authTypeValue))
+ tlv(0x100F, shortToBytes(encType))
+ if (passBytes.isNotEmpty()) tlv(0x1027, passBytes)
+ }
+ }
+
+ /**
+ * Returns (authType, encType) TLV values for a given WPA security string.
+ * Authentication types per WFA WSC spec:
+ * 0x0001 = Open, 0x0002 = WPA-Personal, 0x0004 = WEP, 0x0020 = WPA2-Personal
+ * Encryption types:
+ * 0x0001 = None, 0x0002 = WEP, 0x0008 = AES/CCMP
+ */
+ private fun wpaAuthAndEncTypes(authType: String): Pair = when (authType.uppercase()) {
+ "WPA2", "WPA2-EAP", "WPA2-PERSONAL" -> (0x0020).toShort() to (0x0008).toShort()
+ "WPA", "WPA-PERSONAL" -> (0x0002).toShort() to (0x0008).toShort()
+ "WEP" -> (0x0004).toShort() to (0x0002).toShort()
+ else -> (0x0001).toShort() to (0x0001).toShort() // Open
+ }
+
+ private fun buildTlvRecord(block: TlvBuilder.() -> Unit): ByteArray {
+ return TlvBuilder().apply(block).build()
+ }
+
+ private fun shortToBytes(value: Short): ByteArray =
+ byteArrayOf((value.toInt() shr 8).toByte(), value.toByte())
+
+ private class TlvBuilder {
+ private val buffer = mutableListOf()
+
+ fun tlv(id: Int, value: ByteArray) {
+ buffer.add((id shr 8).toByte())
+ buffer.add(id.toByte())
+ buffer.add((value.size shr 8).toByte())
+ buffer.add(value.size.toByte())
+ buffer.addAll(value.toList())
+ }
+
+ fun build(): ByteArray = buffer.toByteArray()
+ }
+}
diff --git a/app/src/main/java/net/duhow/openbeam/qr/QrResult.kt b/app/src/main/java/net/duhow/openbeam/qr/QrResult.kt
new file mode 100644
index 0000000..c0f5d10
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/qr/QrResult.kt
@@ -0,0 +1,24 @@
+package net.duhow.openbeam.qr
+
+/**
+ * Sealed class hierarchy for decoded QR code content.
+ * Extend with new subclasses to support additional QR code formats.
+ */
+sealed class QrResult {
+
+ /** A URL (http/https/ftp/etc.) */
+ data class Url(val url: String) : QrResult()
+
+ /**
+ * Wi-Fi credentials decoded from the standard Wi-Fi QR format:
+ * `WIFI:T:;S:;P:;;`
+ */
+ data class WifiCredentials(
+ val ssid: String,
+ val password: String,
+ val authType: String,
+ ) : QrResult()
+
+ /** Raw text that doesn't match any known structured format. */
+ data class RawText(val text: String) : QrResult()
+}
diff --git a/app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt b/app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt
new file mode 100644
index 0000000..0387dce
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt
@@ -0,0 +1,91 @@
+package net.duhow.openbeam.qr
+
+import android.graphics.Bitmap
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.DecodeHintType
+import com.google.zxing.MultiFormatReader
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.HybridBinarizer
+
+/**
+ * Scans a [Bitmap] for a QR code and returns the decoded [QrResult], or null if none found.
+ *
+ * Uses ZXing (`com.google.zxing:core`) – no Google Services required.
+ */
+object QrScanner {
+
+ private val HINTS = mapOf(
+ DecodeHintType.POSSIBLE_FORMATS to listOf(com.google.zxing.BarcodeFormat.QR_CODE),
+ DecodeHintType.TRY_HARDER to true,
+ )
+
+ /**
+ * Attempt to decode a QR code from [bitmap].
+ * @return a [QrResult] if a QR was found and decoded, null otherwise.
+ */
+ fun scan(bitmap: Bitmap): QrResult? {
+ val width = bitmap.width
+ val height = bitmap.height
+ val pixels = IntArray(width * height)
+ bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
+
+ val source = RGBLuminanceSource(width, height, pixels)
+ val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
+
+ return runCatching {
+ val result = MultiFormatReader().decode(binaryBitmap, HINTS)
+ parse(result.text)
+ }.getOrNull()
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ /** Parse a raw decoded string into a typed [QrResult]. */
+ private fun parse(raw: String): QrResult {
+ if (isUrl(raw)) return QrResult.Url(raw)
+ parseWifi(raw)?.let { return it }
+ return QrResult.RawText(raw)
+ }
+
+ private fun isUrl(text: String): Boolean =
+ text.startsWith("http://", ignoreCase = true) ||
+ text.startsWith("https://", ignoreCase = true) ||
+ text.startsWith("ftp://", ignoreCase = true)
+
+ /**
+ * Parse standard Wi-Fi QR format:
+ * `WIFI:T:;S:;P:;;`
+ * Fields may appear in any order.
+ */
+ private fun parseWifi(text: String): QrResult.WifiCredentials? {
+ if (!text.startsWith("WIFI:", ignoreCase = true)) return null
+
+ val content = text.removePrefix("WIFI:").removeSuffix(";")
+ val fields = mutableMapOf()
+
+ val regex = Regex("""([A-Z]):([^;]*)""")
+ regex.findAll(content).forEach { match ->
+ fields[match.groupValues[1]] = unescape(match.groupValues[2])
+ }
+
+ val ssid = fields["S"] ?: return null
+ return QrResult.WifiCredentials(
+ ssid = ssid,
+ password = fields["P"] ?: "",
+ authType = fields["T"] ?: "",
+ )
+ }
+
+ /** Unescape backslash-escaped characters in Wi-Fi QR fields. */
+ private fun unescape(value: String): String {
+ // Use a multi-char placeholder that won't appear in Wi-Fi credentials
+ val backslashPlaceholder = "\u001C\u001C" // ASCII FS FS – not valid in SSIDs/passwords
+ return value.replace("\\\\", backslashPlaceholder)
+ .replace("\\;", ";")
+ .replace("\\,", ",")
+ .replace("\\\"", "\"")
+ .replace(backslashPlaceholder, "\\")
+ }
+}
diff --git a/app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt
new file mode 100644
index 0000000..dcdf3da
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt
@@ -0,0 +1,143 @@
+package net.duhow.openbeam.sharing
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.nfc.NdefMessage
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.nfc.tech.Ndef
+import android.nfc.tech.NdefFormatable
+import android.os.Build
+
+/**
+ * Manages NFC foreground dispatch and NDEF tag writing.
+ *
+ * Usage:
+ * 1. Call [startSharing] with the message to send and a callback.
+ * 2. Forward `onNewIntent` to [onNewIntent].
+ * 3. Call [stopSharing] in `onPause` or when done.
+ *
+ * Android Beam (`setNdefPushMessage`) was removed in API 34.
+ * This helper uses foreground dispatch instead: it enables the activity to
+ * intercept NFC tags while in the foreground and write the NDEF message to them.
+ */
+class NfcShareHelper(private val activity: Activity) {
+
+ private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(activity)
+ private var pendingMessage: NdefMessage? = null
+ private var onResult: ((NfcShareState) -> Unit)? = null
+
+ /** True if NFC hardware is present and enabled on this device. */
+ val isAvailable: Boolean
+ get() = adapter?.isEnabled == true
+
+ /**
+ * Enable NFC foreground dispatch so the activity can catch tag discoveries.
+ * @param message NDEF message to write when a tag is tapped.
+ * @param onResult Callback invoked with the result state.
+ */
+ fun startSharing(message: NdefMessage, onResult: (NfcShareState) -> Unit) {
+ if (!isAvailable) {
+ onResult(NfcShareState.Unsupported)
+ return
+ }
+ this.pendingMessage = message
+ this.onResult = onResult
+ enableForegroundDispatch()
+ onResult(NfcShareState.Waiting)
+ }
+
+ /** Disable foreground dispatch. Call from Activity.onPause. */
+ fun stopSharing() {
+ adapter?.disableForegroundDispatch(activity)
+ pendingMessage = null
+ onResult = null
+ }
+
+ /**
+ * Must be called from Activity.onNewIntent.
+ * Handles NFC tag discovery and writes the pending NDEF message.
+ */
+ fun onNewIntent(intent: Intent) {
+ val action = intent.action ?: return
+ if (action != NfcAdapter.ACTION_TAG_DISCOVERED &&
+ action != NfcAdapter.ACTION_NDEF_DISCOVERED &&
+ action != NfcAdapter.ACTION_TECH_DISCOVERED
+ ) return
+
+ val tag: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
+ } ?: return
+
+ val message = pendingMessage ?: return
+ val callback = onResult ?: return
+
+ callback(NfcShareState.Writing)
+ val success = writeNdef(tag, message)
+ callback(if (success) NfcShareState.Success else NfcShareState.Error)
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private fun enableForegroundDispatch() {
+ val intent = Intent(activity, activity.javaClass).apply {
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ activity, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+ adapter?.enableForegroundDispatch(activity, pendingIntent, null, null)
+ }
+
+ /** Write [message] to [tag]. Returns true on success. */
+ private fun writeNdef(tag: Tag, message: NdefMessage): Boolean {
+ // Try NDEF first (tag already formatted)
+ Ndef.get(tag)?.let { ndef ->
+ return runCatching {
+ ndef.connect()
+ check(ndef.isWritable) { "Tag is read-only" }
+ check(ndef.maxSize >= message.byteArrayLength) { "Message too large for tag" }
+ ndef.writeNdefMessage(message)
+ ndef.close()
+ true
+ }.getOrElse { false }
+ }
+
+ // Try NdefFormatable (blank tag that needs formatting)
+ NdefFormatable.get(tag)?.let { formatable ->
+ return runCatching {
+ formatable.connect()
+ formatable.format(message)
+ formatable.close()
+ true
+ }.getOrElse { false }
+ }
+
+ return false
+ }
+}
+
+/** States emitted during an NFC share operation. */
+sealed class NfcShareState {
+ /** Foreground dispatch active, waiting for user to tap a tag. */
+ object Waiting : NfcShareState()
+
+ /** Tag detected, writing NDEF message. */
+ object Writing : NfcShareState()
+
+ /** Message written successfully. */
+ object Success : NfcShareState()
+
+ /** Write failed (tag not writable, too small, I/O error). */
+ object Error : NfcShareState()
+
+ /** NFC is not available or disabled on this device. */
+ object Unsupported : NfcShareState()
+}
diff --git a/app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt b/app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt
new file mode 100644
index 0000000..ea1c377
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt
@@ -0,0 +1,208 @@
+package net.duhow.openbeam.sharing
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.net.wifi.p2p.WifiP2pConfig
+import android.net.wifi.p2p.WifiP2pDevice
+import android.net.wifi.p2p.WifiP2pManager
+import android.os.Build
+import android.os.Looper
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.Socket
+
+/**
+ * Wi-Fi Direct (P2P) file transfer – used as fallback when NFC is unavailable
+ * or the shared image contains no QR code.
+ *
+ * No Google Play Services required. Uses the native [WifiP2pManager] API.
+ *
+ * Transfer flow:
+ * 1. [discoverPeers] – scan for nearby Wi-Fi Direct peers.
+ * 2. [connectToPeer] – negotiate a group with the chosen device.
+ * 3. [sendFile] – transfer the file over a TCP socket inside the P2P group.
+ *
+ * The receiver must also have OpenBeam running in receive mode (not yet
+ * implemented – planned for the next iteration via a background Service).
+ */
+class WifiDirectShare(private val context: Context) {
+
+ companion object {
+ private const val TRANSFER_PORT = 8988
+ private const val SOCKET_TIMEOUT_MS = 30_000
+ private const val BUFFER_SIZE = 8 * 1024
+ }
+
+ private val manager: WifiP2pManager? by lazy {
+ context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
+ }
+ @Volatile private var channel: WifiP2pManager.Channel? = null
+ private var stateCallback: ((WifiShareState) -> Unit)? = null
+
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
+ val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
+ if (state != WifiP2pManager.WIFI_P2P_STATE_ENABLED) {
+ stateCallback?.invoke(WifiShareState.Unavailable)
+ }
+ }
+ WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
+ manager?.requestPeers(channel) { peerList ->
+ stateCallback?.invoke(WifiShareState.PeersDiscovered(peerList.deviceList.toList()))
+ }
+ }
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
+ manager?.requestConnectionInfo(channel) { info ->
+ if (info.groupFormed) {
+ stateCallback?.invoke(WifiShareState.Connected(info.groupOwnerAddress?.hostAddress ?: ""))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /** Register the Wi-Fi Direct broadcast receiver. Call from onResume. */
+ fun register() {
+ channel = manager?.initialize(context, Looper.getMainLooper(), null)
+ val filter = IntentFilter().apply {
+ addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ context.registerReceiver(receiver, filter)
+ }
+ }
+
+ /** Unregister the broadcast receiver. Call from onPause. */
+ fun unregister() {
+ runCatching { context.unregisterReceiver(receiver) }
+ channel?.close()
+ channel = null
+ }
+
+ /**
+ * Start peer discovery. Results delivered via [onState] callbacks:
+ * [WifiShareState.Discovering] then [WifiShareState.PeersDiscovered].
+ */
+ fun discoverPeers(onState: (WifiShareState) -> Unit) {
+ this.stateCallback = onState
+ val ch = channel ?: run { onState(WifiShareState.Unavailable); return }
+ onState(WifiShareState.Discovering)
+ manager?.discoverPeers(ch, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() { /* peers will arrive via broadcast */ }
+ override fun onFailure(reason: Int) {
+ onState(WifiShareState.Error("Peer discovery failed: $reason"))
+ }
+ })
+ }
+
+ /** Connect to a specific [peer] discovered during [discoverPeers]. */
+ fun connectToPeer(peer: WifiP2pDevice, onState: (WifiShareState) -> Unit) {
+ this.stateCallback = onState
+ val ch = channel ?: run { onState(WifiShareState.Unavailable); return }
+ val config = WifiP2pConfig().apply { deviceAddress = peer.deviceAddress }
+ manager?.connect(ch, config, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() { onState(WifiShareState.Connecting) }
+ override fun onFailure(reason: Int) {
+ onState(WifiShareState.Error("Connect failed: $reason"))
+ }
+ })
+ }
+
+ /**
+ * Send [fileUri] to [groupOwnerAddress] once a Wi-Fi Direct group is formed.
+ * The remote device must be listening on [TRANSFER_PORT].
+ *
+ * Call this after receiving [WifiShareState.Connected] from [connectToPeer].
+ */
+ fun sendFile(
+ fileUri: Uri,
+ groupOwnerAddress: String,
+ onState: (WifiShareState) -> Unit,
+ ) {
+ onState(WifiShareState.Transferring)
+ Thread {
+ runCatching {
+ val inputStream: InputStream = context.contentResolver.openInputStream(fileUri)
+ ?: error("Cannot open file URI")
+
+ inputStream.use { input ->
+ val socket = Socket()
+ socket.use {
+ socket.bind(null)
+ socket.connect(InetSocketAddress(groupOwnerAddress, TRANSFER_PORT), SOCKET_TIMEOUT_MS)
+ val output: OutputStream = socket.getOutputStream()
+ val buffer = ByteArray(BUFFER_SIZE)
+ var bytesRead: Int
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ }
+ output.flush()
+ }
+ }
+ onState(WifiShareState.Done)
+ }.onFailure { e ->
+ onState(WifiShareState.Error(e.message ?: "Transfer error"))
+ }
+ }.start()
+ }
+
+ /**
+ * Receive a file from a peer (act as group owner / server).
+ * Listens on [TRANSFER_PORT], writes incoming bytes to [outputUri].
+ */
+ fun receiveFile(
+ outputUri: Uri,
+ onState: (WifiShareState) -> Unit,
+ ) {
+ onState(WifiShareState.Receiving)
+ Thread {
+ runCatching {
+ val serverSocket = ServerSocket(TRANSFER_PORT)
+ serverSocket.use { server ->
+ val client = server.accept()
+ client.use {
+ val input: InputStream = client.getInputStream()
+ val outputStream = context.contentResolver.openOutputStream(outputUri)
+ ?: error("Cannot open output URI")
+ outputStream.use { output ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var bytesRead: Int
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ }
+ }
+ }
+ }
+ onState(WifiShareState.Done)
+ }.onFailure { e ->
+ onState(WifiShareState.Error(e.message ?: "Receive error"))
+ }
+ }.start()
+ }
+}
+
+/** States emitted during a Wi-Fi Direct share operation. */
+sealed class WifiShareState {
+ object Discovering : WifiShareState()
+ object Connecting : WifiShareState()
+ object Transferring : WifiShareState()
+ object Receiving : WifiShareState()
+ object Done : WifiShareState()
+ object Unavailable : WifiShareState()
+ data class PeersDiscovered(val peers: List) : WifiShareState()
+ data class Connected(val groupOwnerAddress: String) : WifiShareState()
+ data class Error(val message: String) : WifiShareState()
+}
diff --git a/app/src/main/java/net/duhow/openbeam/util/SoundManager.kt b/app/src/main/java/net/duhow/openbeam/util/SoundManager.kt
new file mode 100644
index 0000000..94c5f68
--- /dev/null
+++ b/app/src/main/java/net/duhow/openbeam/util/SoundManager.kt
@@ -0,0 +1,59 @@
+package net.duhow.openbeam.util
+
+import android.content.Context
+import android.media.AudioManager
+import android.media.MediaPlayer
+import android.media.RingtoneManager
+import android.net.Uri
+
+/**
+ * Plays UI sound feedback during sharing interactions.
+ *
+ * Sound effects:
+ * - [playBeam] – NFC tag detected / sending
+ * - [playSuccess] – Sharing completed successfully
+ * - [playError] – Sharing failed
+ * - [playTap] – Generic button tap
+ */
+object SoundManager {
+
+ /**
+ * Play the "beam" sound – emitted when an NFC tag is detected and we start writing.
+ * Uses the system camera shutter / focus sound as a proxy for "beaming".
+ */
+ fun playBeam(context: Context) {
+ playSystemSound(context, AudioManager.FX_FOCUS_NAVIGATION_UP)
+ }
+
+ /** Play the success notification sound (standard Android notification). */
+ fun playSuccess(context: Context) {
+ playRingtone(context, RingtoneManager.TYPE_NOTIFICATION)
+ }
+
+ /** Play the error / failure sound. */
+ fun playError(context: Context) {
+ playSystemSound(context, AudioManager.FX_KEYPRESS_DELETE)
+ }
+
+ /** Play a light tap / click sound (button interaction). */
+ fun playTap(context: Context) {
+ playSystemSound(context, AudioManager.FX_KEY_CLICK)
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private fun playSystemSound(context: Context, effectType: Int) {
+ val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
+ am.playSoundEffect(effectType, 1.0f)
+ }
+
+ private fun playRingtone(context: Context, type: Int) {
+ runCatching {
+ val uri: Uri = RingtoneManager.getDefaultUri(type) ?: return
+ val ringtone = RingtoneManager.getRingtone(context, uri) ?: return
+ ringtone.play()
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/bg_share_dialog.xml b/app/src/main/res/drawable/bg_share_dialog.xml
new file mode 100644
index 0000000..20f4482
--- /dev/null
+++ b/app/src/main/res/drawable/bg_share_dialog.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_fg.xml b/app/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..0599f1f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nfc.xml b/app/src/main/res/drawable/ic_nfc.xml
new file mode 100644
index 0000000..e747941
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nfc.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_wifi.xml b/app/src/main/res/drawable/ic_wifi.xml
new file mode 100644
index 0000000..39cd22f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_wifi.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout-land/activity_share.xml b/app/src/main/res/layout-land/activity_share.xml
new file mode 100644
index 0000000..11f7b8d
--- /dev/null
+++ b/app/src/main/res/layout-land/activity_share.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml
new file mode 100644
index 0000000..aa14558
--- /dev/null
+++ b/app/src/main/res/layout/activity_share.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..298f457
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..298f457
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 4e9b9e2..2cdca48 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -1,3 +1,28 @@
- My App
+ OpenBeam
+
+
+ Acosta\'l a un altre dispositiu
+ Cercant codi QR…
+ Escrivint…
+ Fet!
+ Alguna cosa ha anat malament
+ Tipus de contingut no compatible
+ NFC no disponible
+ Cercant dispositius propers…
+ No s\'han trobat dispositius
+ Connectant…
+ Transferint…
+ Wi-Fi Direct no disponible
+
+
+ Toca la part del darrere d\'un altre dispositiu per compartir
+ Activa el NFC a Configuració
+ Assegura\'t que l\'altre dispositiu també té OpenBeam obert
+
+
+ Cancel·la
+
+
+ Icona del mètode de compartició
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 4e9b9e2..505b477 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1,3 +1,28 @@
- My App
+ OpenBeam
+
+
+ Acerca el dispositivo a otro
+ Buscando código QR…
+ Escribiendo…
+ ¡Listo!
+ Algo salió mal
+ Tipo de contenido no compatible
+ NFC no disponible
+ Buscando dispositivos cercanos…
+ No se encontraron dispositivos
+ Conectando…
+ Transfiriendo…
+ Wi-Fi Direct no disponible
+
+
+ Toca la parte trasera de otro dispositivo para compartir
+ Activa el NFC en Ajustes
+ Asegúrate de que el otro dispositivo también tiene OpenBeam abierto
+
+
+ Cancelar
+
+
+ Icono del método de compartición
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..8cb1525
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,13 @@
+
+
+
+ #1565C0
+ #003C8F
+ #29B6F6
+ #FFFFFF
+
+
+ #FF1E1E1E
+ #FFFFFFFF
+ #FFBBBBBB
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4e9b9e2..8132463 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,28 @@
- My App
+ OpenBeam
+
+
+ Hold near another device
+ Scanning for QR code…
+ Writing…
+ Done!
+ Something went wrong
+ Unsupported content type
+ NFC not available
+ Looking for nearby devices…
+ No nearby devices found
+ Connecting…
+ Transferring…
+ Wi-Fi Direct not available
+
+
+ Tap the back of another device to share
+ Please enable NFC in Settings
+ Make sure the other device also has OpenBeam open
+
+
+ Cancel
+
+
+ Share method icon
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f6dbd54
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/android-beam-research.md b/docs/android-beam-research.md
new file mode 100644
index 0000000..4a2203b
--- /dev/null
+++ b/docs/android-beam-research.md
@@ -0,0 +1,141 @@
+# Android Beam – Research Notes
+
+## Overview
+
+**Android Beam** was Google's NFC-based peer-to-peer data sharing feature introduced in Android 4.0 (Ice Cream Sandwich). It allowed two NFC-enabled Android phones to exchange data simply by touching them back-to-back.
+
+**Deprecated in Android 10 (API 29)**, removed in **Android 14 (API 34)**.
+
+Source: [Android developer blog / NFC guide]
+
+---
+
+## How Android Beam Worked (Technically)
+
+### Key API (Now Deprecated/Removed)
+
+```java
+NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context);
+// Push a single static message
+adapter.setNdefPushMessage(ndefMessage, activity);
+// Or via callback (lazy construction)
+adapter.setNdefPushMessageCallback(callback, activity);
+// Callback when the other device received it
+adapter.setOnNdefPushCompleteCallback(callback, activity);
+```
+
+These APIs (`setNdefPushMessage`, `setNdefPushMessageCallback`, `setOnNdefPushCompleteCallback`) are **all removed in API 34**.
+
+### Transport
+
+Android Beam used NFC for initial handshake then fell back to:
+- **Bluetooth** for large files (SNEP – Simple NDEF Exchange Protocol over Bluetooth)
+- **Wi-Fi Direct** for very large files (Connection Handover via NDEF)
+
+The NFC merely exchanged handover records; the real data went over BT/WiFi.
+
+---
+
+## NDEF Message Formats We Need
+
+### URL Record
+```
+RTD: RTD_URI
+Payload: <1-byte prefix code> +
+```
+Android SDK: `NdefRecord.createUri(Uri.parse("https://example.com"))`
+
+### Text Record (Plain Text / Fallback)
+```
+RTD: RTD_TEXT
+Payload: + +
+```
+Android SDK: `NdefRecord.createTextRecord("en", "Hello")`
+
+### vCard (Contact)
+```
+MIME type: "text/vcard" or "text/x-vcard"
+Payload: raw vCard bytes
+```
+Android SDK: `NdefRecord.createMime("text/vcard", vcardBytes)`
+
+### Wi-Fi Credentials (WFA WSC)
+```
+MIME type: "application/vnd.wfa.wsc"
+Payload: TLV-encoded Wi-Fi Simple Configuration data
+```
+Defined by Wi-Fi Alliance "Wi-Fi Protected Setup" spec.
+
+Key TLV attributes:
+| Attribute ID | Description | Value |
+|--------------|--------------------|------------------------|
+| 0x1045 | SSID | UTF-8 string |
+| 0x1003 | Authentication Type| 0x0001=Open, 0x0020=WPA2 |
+| 0x100F | Encryption Type | 0x0001=None, 0x0008=AES |
+| 0x1027 | Network Key | UTF-8 password |
+
+### External Type (Custom / Future)
+```
+TNF: NdefRecord.TNF_EXTERNAL_TYPE
+Type: "net.duhow.openbeam:"
+Payload: custom bytes
+```
+
+---
+
+## NFC Foreground Dispatch (Current API)
+
+On Android 10+ the way to handle NFC in the foreground is via `NfcAdapter.enableForegroundDispatch`:
+
+```kotlin
+val intent = Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+val pendingIntent = PendingIntent.getActivity(this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
+val filters = arrayOf(
+ IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED),
+ IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED),
+)
+nfcAdapter.enableForegroundDispatch(activity, pendingIntent, filters, null)
+```
+
+When the tag is discovered, the intent arrives via `onNewIntent`. Then you can:
+1. **Read** NDEF from the tag
+2. **Write** NDEF to a writable tag
+
+---
+
+## NFC Host Card Emulation (HCE) – Device-to-Device
+
+For device-to-device without physical NFC tags, Android supports **HCE** (API 19+):
+- One device emulates an NFC tag
+- The other reads it
+- Uses ISO-DEP / APDU protocol
+
+This requires:
+- A `HostApduService` subclass
+- AID registration in AndroidManifest
+- Custom APDU command/response protocol
+
+This approach can simulate Android Beam behavior and is the most faithful reproduction on modern Android. It's complex but feasible.
+
+**Reference implementation**: [NDEF over HCE](https://developer.android.com/develop/connectivity/nfc/hce)
+
+---
+
+## Reference Projects
+
+- **jzadl/openbeam**: https://github.com/jzadl/openbeam – NFC handshake + Wi-Fi Direct / Bluetooth transport. Java/Kotlin, Gradle 8.7. Uses Nearby Connections API for data transfer.
+- **NFC Tools**: Various NFC tag reader/writer apps on FOSS repos.
+- **ShareViaHttp**: Different approach – Wi-Fi hotspot + HTTP server for file transfer.
+
+---
+
+## Our Strategy
+
+Since Android Beam APIs are removed (API 34+, our targetSdk=35):
+1. Use **NFC foreground dispatch** to detect nearby NFC tags or devices
+2. For simple data (URL, vCard, text) → **write NDEF to tag** or use **HCE** to emulate a tag
+3. For files without QR → fall back to **Wi-Fi Direct** file transfer
+4. Support **reading** NDEF records when another device shares to us
+
+The NFC handshake + Wi-Fi Direct transfer approach (as in jzadl/openbeam) is ideal for future full implementation.
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
new file mode 100644
index 0000000..50e6608
--- /dev/null
+++ b/docs/implementation-plan.md
@@ -0,0 +1,135 @@
+# OpenBeam – Implementation Plan
+
+## Goal
+
+Clone the "Android Beam" experience for Android 10+ (Android Beam APIs removed). OpenBeam intercepts share intents, creates NDEF messages from the shared content, and transfers them via NFC tag write or Wi-Fi Direct.
+
+---
+
+## Architecture
+
+```
+User presses "Share" in any app
+ → OS shows share sheet
+ → User selects "OpenBeam"
+ → ShareActivity launched (dialog-style overlay)
+ ↓
+ Inspect shared content type:
+ ┌──────────────────────────────────────────┐
+ │ text/plain, text/vcard, text/x-vcard │
+ │ → NdefHelper.fromContent() │
+ │ → NfcShareHelper.startSharing() │
+ │ → Show "Tap another device" UI │
+ └──────────────────────────────────────────┘
+ ┌──────────────────────────────────────────┐
+ │ image/* │
+ │ → QrScanner.scan(bitmap) │
+ │ If QR found: │
+ │ → NdefHelper.fromQrResult() │
+ │ → NfcShareHelper.startSharing() │
+ │ If no QR: │
+ │ → WifiDirectShare.send(uri) │
+ └──────────────────────────────────────────┘
+```
+
+---
+
+## Key Components
+
+### 1. `NdefContent` (sealed class)
+Represents shareable content types. Extensible for future types.
+
+```
+NdefContent
+├── Url(url: String)
+├── VCard(vcard: String)
+├── PlainText(text: String)
+└── WifiCredentials(ssid, password, authType)
+```
+
+### 2. `NdefHelper`
+Converts `NdefContent` → `android.nfc.NdefMessage`.
+
+### 3. `QrResult` (sealed class) + `QrScanner`
+- `QrScanner.scan(bitmap: Bitmap): QrResult?` using ZXing `MultiFormatReader`
+- Parses WiFi QR format: `WIFI:T:WPA;S:ssid;P:pass;;`
+- Returns `QrResult.Url`, `QrResult.WifiCredentials`, or `QrResult.RawText`
+
+### 4. `NfcShareHelper`
+- Manages NFC foreground dispatch lifecycle
+- `startSharing(message)`: enables dispatch, waits for tag
+- `onTagDiscovered(tag, message)`: writes NDEF to tag or exchanges via HCE
+- Emits `NfcShareState` (Waiting, Writing, Success, Error, Unsupported)
+
+### 5. `WifiDirectShare`
+- Uses `WifiP2pManager` for peer discovery and connection
+- Transfers files via TCP socket after Wi-Fi Direct group is formed
+- Emits `WifiShareState` (Discovering, Connecting, Transferring, Done, Error)
+
+### 6. `SoundManager`
+- `playBeam()` – NFC tap / sending sound (system `AudioManager.FX_KEY_CLICK`)
+- `playSuccess()` – sharing complete
+- `playError()` – sharing failed
+
+### 7. `ShareActivity`
+- `launchMode="singleTop"`, `excludeFromRecents="true"`
+- Theme: `Theme.OpenBeam.Dialog` – transparent background, dialog window
+- Handles `ACTION_SEND` and `ACTION_SEND_MULTIPLE`
+- Calls `onNewIntent` to receive NFC intents while in foreground
+- Auto-finishes on success, error, or cancel
+
+---
+
+## MIME / Intent Filters
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Future Work
+
+- [ ] NFC HCE (HostApduService) for device-to-device without physical tags
+- [ ] Bluetooth RFCOMM as second fallback for small payloads
+- [ ] Receive mode: read NDEF from incoming tags / HCE devices
+- [ ] Multiple images (`ACTION_SEND_MULTIPLE`)
+- [ ] Google Nearby Connections as optional dependency for GMS devices
+- [ ] Settings screen (preferred transport, sound toggle)
+- [ ] NFC-triggered Wi-Fi Direct handover (full Android Beam simulation)
+
+---
+
+## Dependencies
+
+| Library | Version | License | Purpose |
+|------------------------------|---------|------------|----------------------|
+| `androidx.core:core-ktx` | 1.16.0 | Apache 2.0 | Kotlin extensions |
+| `androidx.appcompat:appcompat`| 1.7.0 | Apache 2.0 | Activity/Fragment |
+| `com.google.android.material`| 1.12.0 | Apache 2.0 | Material UI |
+| `androidx.constraintlayout` | 2.2.0 | Apache 2.0 | Layout |
+| `com.google.zxing:core` | 3.5.3 | Apache 2.0 | QR code scanning |
+
+No Google Services (GMS) dependencies in the initial version.
diff --git a/docs/quick-share-research.md b/docs/quick-share-research.md
new file mode 100644
index 0000000..ea45212
--- /dev/null
+++ b/docs/quick-share-research.md
@@ -0,0 +1,144 @@
+# Quick Share / Nearby Share – Research Notes
+
+## Overview
+
+**Quick Share** (formerly "Nearby Share" on Android, "Quick Share" on Samsung) is Google's file-sharing feature introduced in Android 6.0. It uses a combination of Bluetooth, Wi-Fi Direct, and WebRTC to share files, links, and more between nearby devices.
+
+**Key difference from Android Beam**: Quick Share is asynchronous – no need to physically touch devices. Discovery happens automatically.
+
+---
+
+## Google's Nearby Connections API
+
+Source: [Google Play Services – Nearby Connections](https://developers.google.com/nearby/connections/overview)
+
+### How it works
+1. **Advertising**: One device broadcasts availability
+2. **Discovery**: Other device scans for advertisers
+3. **Connection**: Request + accept handshake
+4. **Transfer**: Payload exchange (bytes, files, streams)
+
+### Transport protocols used internally
+| Strategy | Description |
+|------------------|-------------------------------------------------|
+| P2P_STAR | Hub-and-spoke (one host, many clients) |
+| P2P_CLUSTER | Mesh (all devices connect to all) |
+| P2P_POINT_TO_POINT| Single direct connection |
+
+### SDK dependency (requires Google Play Services)
+```groovy
+implementation 'com.google.android.gms:play-services-nearby:19.3.0'
+```
+
+**Problem**: Requires Google Play Services → not available on de-Googled devices (e.g. GrapheneOS, LineageOS without GMS).
+
+---
+
+## OSS Alternatives (No Google Services Required)
+
+### 1. Wi-Fi Direct (WifiP2pManager) – Native Android API
+
+The most portable approach. Built into Android since API 14.
+
+```kotlin
+val manager = getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager
+val channel = manager.initialize(this, mainLooper, null)
+
+// Discovery
+manager.discoverPeers(channel, object : WifiP2pManager.ActionListener { ... })
+
+// Connect
+val config = WifiP2pConfig().apply { deviceAddress = peerMacAddress }
+manager.connect(channel, config, object : WifiP2pManager.ActionListener { ... })
+
+// Transfer: one device runs a ServerSocket, other connects as client
+```
+
+**Required permissions**:
+- `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`
+- `ACCESS_FINE_LOCATION` (< API 33) or `NEARBY_WIFI_DEVICES` (API 33+)
+
+**Pros**: No external dependencies, works on all Android forks.
+**Cons**: Peer discovery can be slow (~5-10s). Group owner elected automatically.
+
+### 2. Bluetooth RFCOMM – Classic Bluetooth
+
+For small payloads (vCards, URLs, small images).
+
+```kotlin
+val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
+// Server socket
+val serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("OpenBeam", MY_UUID)
+// Client connection
+val socket = device.createRfcommSocketToServiceRecord(MY_UUID)
+```
+
+**Pros**: Wide device compatibility, no location permission on API 31+.
+**Cons**: Slow throughput for large files.
+
+### 3. mDNS + HTTP (LAN Transfer)
+
+Advertise an HTTP server over mDNS. Other devices discover and download.
+
+**Libraries**: `NsdManager` (Android built-in), `jmdns` (LGPL).
+**Pros**: Works across platforms (Android, iOS, PC).
+**Cons**: Requires both devices on same network.
+
+---
+
+## Our Strategy for OpenBeam
+
+Since the goal is avoiding Google Services:
+
+1. **Primary**: Wi-Fi Direct (`WifiP2pManager`) for file transfer
+ - NFC provides the handshake (exchange connection params)
+ - Wi-Fi Direct does the actual transfer
+2. **Secondary**: Bluetooth RFCOMM for small payloads (vCards, URLs < 10KB)
+3. **Optional / future**: Nearby Connections API as opt-in for Google-devices compatibility
+
+### Transfer Flow
+
+```
+User shares image/file
+ → No QR found in image (or not an image)
+ → WifiDirectShare.send(file, peer)
+ → enableForegroundDispatch (NFC)
+ → When NFC tag detected: exchange Wi-Fi Direct connection params via NDEF
+ → Open WifiP2pManager group
+ → Transfer file over TCP socket
+ → Notify completion → close activity
+```
+
+---
+
+## Relevant NDEF Records for Handover
+
+### Connection Handover Request (Wi-Fi Direct)
+```
+TNF: NdefRecord.TNF_WELL_KNOWN
+Type: "Hr" (Handover Request)
+Payload: Version byte + Alternative carrier record(s)
+```
+
+### Wi-Fi Direct carrier record (WFA P2P)
+```
+MIME type: "application/vnd.wfa.p2p"
+Payload: WPS TLV data (SSID, passphrase, channel)
+```
+
+This is what the jzadl/openbeam project uses for the NFC handshake.
+
+---
+
+## Permissions Summary
+
+| Feature | Permission | API level |
+|-------------------|-----------------------------------------------|-----------|
+| NFC | `android.permission.NFC` | all |
+| NFC HCE | `android.permission.NFC` | 19+ |
+| Wi-Fi Direct | `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | all |
+| Wi-Fi peer scan | `ACCESS_FINE_LOCATION` | < 33 |
+| Wi-Fi peer scan | `NEARBY_WIFI_DEVICES` | 33+ |
+| Bluetooth scan | `BLUETOOTH_SCAN` | 31+ |
+| Bluetooth connect | `BLUETOOTH_CONNECT` | 31+ |
+| Internet | `INTERNET` | all |
diff --git a/fastlane/Appfile b/fastlane/Appfile
index e16ef23..2ee1555 100644
--- a/fastlane/Appfile
+++ b/fastlane/Appfile
@@ -1,3 +1,3 @@
json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
-package_name("net.duhowpi.myapp")
+package_name("net.duhow.openbeam")
diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt
index 58f9059..0c3d035 100644
--- a/fastlane/metadata/android/en-US/changelogs/1.txt
+++ b/fastlane/metadata/android/en-US/changelogs/1.txt
@@ -1,3 +1,6 @@
Initial release.
-- Write your changes here.
\ No newline at end of file
+- Share URLs, vCards and plain text via NFC tag write
+- Scan images for QR codes and beam the decoded content (URLs, Wi-Fi credentials) via NFC
+- Wi-Fi Direct fallback when image has no QR code or NFC is unavailable
+- Supports English, Spanish and Catalan
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 6d4306b..02a0274 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1 +1,7 @@
-Full description of app.
+OpenBeam revives the Android Beam tap-to-share experience on Android 8.0 and newer – no Google Play Services required.
+
+Select "OpenBeam" from the system share sheet to share URLs, contacts, or images with other nearby devices.
+
+For URLs and contacts, simply tap your phone against an NFC tag to write the data. For images, OpenBeam first scans for a QR code: if one is found, the decoded content (URL or Wi-Fi credentials) is written to an NFC tag. If no QR code is present, the file is transferred over Wi-Fi Direct.
+
+OpenBeam does not appear in the app drawer and runs only when you explicitly share through it.
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index 6e69b74..dac0657 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Short description of app.
+Modern Android Beam replacement – NFC + Wi-Fi Direct tap-to-share
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
index 2ab2073..b9573fc 100644
--- a/fastlane/metadata/android/en-US/title.txt
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -1 +1 @@
-My App
+OpenBeam
diff --git a/settings.gradle b/settings.gradle
index 652afc1..f43d14b 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -13,5 +13,5 @@ dependencyResolutionManagement {
}
}
-rootProject.name = "My App"
+rootProject.name = "OpenBeam"
include ':app'
From a08553a8a991911e06f74ca57b3ebf8870683446 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Jul 2026 08:47:22 +0000
Subject: [PATCH 3/6] fix: correct package ID to net.duhowpi.openbeam and fix
unclosed comment
---
app/build.gradle | 4 ++--
.../openbeam/ShareActivity.kt | 22 +++++++++----------
.../openbeam/ndef/NdefContent.kt | 2 +-
.../openbeam/ndef/NdefHelper.kt | 2 +-
.../openbeam/qr/QrResult.kt | 2 +-
.../openbeam/qr/QrScanner.kt | 2 +-
.../openbeam/sharing/NfcShareHelper.kt | 2 +-
.../openbeam/sharing/WifiDirectShare.kt | 2 +-
.../openbeam/util/SoundManager.kt | 2 +-
docs/android-beam-research.md | 2 +-
fastlane/Appfile | 2 +-
11 files changed, 22 insertions(+), 22 deletions(-)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/ShareActivity.kt (95%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/ndef/NdefContent.kt (96%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/ndef/NdefHelper.kt (99%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/qr/QrResult.kt (95%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/qr/QrScanner.kt (99%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/sharing/NfcShareHelper.kt (99%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/sharing/WifiDirectShare.kt (99%)
rename app/src/main/java/net/{duhow => duhowpi}/openbeam/util/SoundManager.kt (98%)
diff --git a/app/build.gradle b/app/build.gradle
index 56c0272..1a4f535 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -26,11 +26,11 @@ def getGitVersionCode = { ->
}
android {
- namespace 'net.duhow.openbeam'
+ namespace 'net.duhowpi.openbeam'
compileSdk 35
defaultConfig {
- applicationId "net.duhow.openbeam"
+ applicationId "net.duhowpi.openbeam"
minSdk 26
targetSdk 35
versionCode getGitVersionCode()
diff --git a/app/src/main/java/net/duhow/openbeam/ShareActivity.kt b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
similarity index 95%
rename from app/src/main/java/net/duhow/openbeam/ShareActivity.kt
rename to app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
index 8a08008..edaae6b 100644
--- a/app/src/main/java/net/duhow/openbeam/ShareActivity.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam
+package net.duhowpi.openbeam
import android.content.Intent
import android.graphics.Bitmap
@@ -14,15 +14,15 @@ import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
-import net.duhow.openbeam.ndef.NdefContent
-import net.duhow.openbeam.ndef.NdefHelper
-import net.duhow.openbeam.qr.QrResult
-import net.duhow.openbeam.qr.QrScanner
-import net.duhow.openbeam.sharing.NfcShareHelper
-import net.duhow.openbeam.sharing.NfcShareState
-import net.duhow.openbeam.sharing.WifiDirectShare
-import net.duhow.openbeam.sharing.WifiShareState
-import net.duhow.openbeam.util.SoundManager
+import net.duhowpi.openbeam.ndef.NdefContent
+import net.duhowpi.openbeam.ndef.NdefHelper
+import net.duhowpi.openbeam.qr.QrResult
+import net.duhowpi.openbeam.qr.QrScanner
+import net.duhowpi.openbeam.sharing.NfcShareHelper
+import net.duhowpi.openbeam.sharing.NfcShareState
+import net.duhowpi.openbeam.sharing.WifiDirectShare
+import net.duhowpi.openbeam.sharing.WifiShareState
+import net.duhowpi.openbeam.util.SoundManager
/**
* Transparent overlay activity that handles all incoming share intents.
@@ -33,7 +33,7 @@ import net.duhow.openbeam.util.SoundManager
*
* Share flow:
* • text/plain, text/vcard → NDEF message → NFC tag write
- * • image/* → QR scan → NDEF (if QR found)
+ * • image (any) → QR scan → NDEF (if QR found)
* → Wi-Fi Direct (if no QR / NFC unavailable)
*/
class ShareActivity : AppCompatActivity() {
diff --git a/app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt b/app/src/main/java/net/duhowpi/openbeam/ndef/NdefContent.kt
similarity index 96%
rename from app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt
rename to app/src/main/java/net/duhowpi/openbeam/ndef/NdefContent.kt
index a8469e3..9f7ed44 100644
--- a/app/src/main/java/net/duhow/openbeam/ndef/NdefContent.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/ndef/NdefContent.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.ndef
+package net.duhowpi.openbeam.ndef
/**
* Sealed class hierarchy representing all content types that OpenBeam can share via NDEF.
diff --git a/app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt b/app/src/main/java/net/duhowpi/openbeam/ndef/NdefHelper.kt
similarity index 99%
rename from app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt
rename to app/src/main/java/net/duhowpi/openbeam/ndef/NdefHelper.kt
index 81be8cc..9738232 100644
--- a/app/src/main/java/net/duhow/openbeam/ndef/NdefHelper.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/ndef/NdefHelper.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.ndef
+package net.duhowpi.openbeam.ndef
import android.nfc.NdefMessage
import android.nfc.NdefRecord
diff --git a/app/src/main/java/net/duhow/openbeam/qr/QrResult.kt b/app/src/main/java/net/duhowpi/openbeam/qr/QrResult.kt
similarity index 95%
rename from app/src/main/java/net/duhow/openbeam/qr/QrResult.kt
rename to app/src/main/java/net/duhowpi/openbeam/qr/QrResult.kt
index c0f5d10..bd40d6c 100644
--- a/app/src/main/java/net/duhow/openbeam/qr/QrResult.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/qr/QrResult.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.qr
+package net.duhowpi.openbeam.qr
/**
* Sealed class hierarchy for decoded QR code content.
diff --git a/app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt b/app/src/main/java/net/duhowpi/openbeam/qr/QrScanner.kt
similarity index 99%
rename from app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt
rename to app/src/main/java/net/duhowpi/openbeam/qr/QrScanner.kt
index 0387dce..298c493 100644
--- a/app/src/main/java/net/duhow/openbeam/qr/QrScanner.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/qr/QrScanner.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.qr
+package net.duhowpi.openbeam.qr
import android.graphics.Bitmap
import com.google.zxing.BinaryBitmap
diff --git a/app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt
similarity index 99%
rename from app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt
rename to app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt
index dcdf3da..2e1b508 100644
--- a/app/src/main/java/net/duhow/openbeam/sharing/NfcShareHelper.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.sharing
+package net.duhowpi.openbeam.sharing
import android.app.Activity
import android.app.PendingIntent
diff --git a/app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/WifiDirectShare.kt
similarity index 99%
rename from app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt
rename to app/src/main/java/net/duhowpi/openbeam/sharing/WifiDirectShare.kt
index ea1c377..d5852f1 100644
--- a/app/src/main/java/net/duhow/openbeam/sharing/WifiDirectShare.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/sharing/WifiDirectShare.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.sharing
+package net.duhowpi.openbeam.sharing
import android.content.BroadcastReceiver
import android.content.Context
diff --git a/app/src/main/java/net/duhow/openbeam/util/SoundManager.kt b/app/src/main/java/net/duhowpi/openbeam/util/SoundManager.kt
similarity index 98%
rename from app/src/main/java/net/duhow/openbeam/util/SoundManager.kt
rename to app/src/main/java/net/duhowpi/openbeam/util/SoundManager.kt
index 94c5f68..43bdf79 100644
--- a/app/src/main/java/net/duhow/openbeam/util/SoundManager.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/util/SoundManager.kt
@@ -1,4 +1,4 @@
-package net.duhow.openbeam.util
+package net.duhowpi.openbeam.util
import android.content.Context
import android.media.AudioManager
diff --git a/docs/android-beam-research.md b/docs/android-beam-research.md
index 4a2203b..fde9957 100644
--- a/docs/android-beam-research.md
+++ b/docs/android-beam-research.md
@@ -77,7 +77,7 @@ Key TLV attributes:
### External Type (Custom / Future)
```
TNF: NdefRecord.TNF_EXTERNAL_TYPE
-Type: "net.duhow.openbeam:"
+Type: "net.duhowpi.openbeam:"
Payload: custom bytes
```
diff --git a/fastlane/Appfile b/fastlane/Appfile
index 2ee1555..07843a9 100644
--- a/fastlane/Appfile
+++ b/fastlane/Appfile
@@ -1,3 +1,3 @@
json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
-package_name("net.duhow.openbeam")
+package_name("net.duhowpi.openbeam")
From 46baa8519ae9515b0d715e72849e7af79a9a455d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:04:21 +0000
Subject: [PATCH 4/6] fix: show share UI, add runtime permissions, diagnostics
- Switch themes from Material2 (MaterialComponents) to Material3
to fix crash: Widget.Material3.Button.TextButton requires
Theme.Material3 parent; setContentView was throwing
IllegalArgumentException before any UI appeared.
- Defer NFC foreground dispatch to onResume: NfcAdapter.
enableForegroundDispatch() throws IllegalStateException when
called before the activity is resumed. shareViaNfc() now stores
the pending NdefMessage and onResume() enables dispatch.
Already-resumed calls (e.g. post-QR scan) start NFC immediately
via lifecycle.currentState.isAtLeast(RESUMED) check.
- Add runtime permission handling for Wi-Fi Direct: discoverPeers()
requires ACCESS_FINE_LOCATION (API <33) or NEARBY_WIFI_DEVICES
(API >=33). Show rationale in dialog, request via
ActivityCompat.requestPermissions, resume transfer on grant.
- Add diagnostics: Log.d/e throughout (filter: adb logcat -s
OpenBeam). On any error state show 'Copy debug info' button that
copies device/version/NFC state and last error to clipboard.
- Add strings: status_permission_needed/denied,
hint_permission_wifi/denied, btn_copy_debug, debug_info_copied
across EN/ES/CA locales.
---
.../net/duhowpi/openbeam/ShareActivity.kt | 201 ++++++++++++++++--
.../main/res/layout-land/activity_share.xml | 11 +
app/src/main/res/layout/activity_share.xml | 12 ++
app/src/main/res/values-ca/strings.xml | 8 +
app/src/main/res/values-es/strings.xml | 8 +
app/src/main/res/values/strings.xml | 8 +
app/src/main/res/values/themes.xml | 12 +-
7 files changed, 238 insertions(+), 22 deletions(-)
diff --git a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
index edaae6b..088e12d 100644
--- a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
@@ -1,6 +1,11 @@
package net.duhowpi.openbeam
+import android.Manifest
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
@@ -8,12 +13,16 @@ import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
+import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
+import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.lifecycle.Lifecycle
import net.duhowpi.openbeam.ndef.NdefContent
import net.duhowpi.openbeam.ndef.NdefHelper
import net.duhowpi.openbeam.qr.QrResult
@@ -35,9 +44,17 @@ import net.duhowpi.openbeam.util.SoundManager
* • text/plain, text/vcard → NDEF message → NFC tag write
* • image (any) → QR scan → NDEF (if QR found)
* → Wi-Fi Direct (if no QR / NFC unavailable)
+ *
+ * Debug: filter logcat by tag "OpenBeam" to follow the full sharing lifecycle.
+ * On error, tap "Copy debug info" in the dialog and paste into a bug report.
*/
class ShareActivity : AppCompatActivity() {
+ companion object {
+ private const val TAG = "OpenBeam"
+ private const val REQ_WIFI_PERMISSION = 1001
+ }
+
private lateinit var nfcHelper: NfcShareHelper
private lateinit var wifiShare: WifiDirectShare
@@ -46,6 +63,21 @@ class ShareActivity : AppCompatActivity() {
private lateinit var progressBar: ProgressBar
private lateinit var ivIcon: ImageView
private lateinit var btnCancel: Button
+ private lateinit var btnDebug: Button
+
+ /**
+ * NFC message ready to write, prepared in [handleShareIntent] (called from [onCreate]).
+ * Actual foreground dispatch is enabled only in [onResume] because
+ * [android.nfc.NfcAdapter.enableForegroundDispatch] requires the activity to be resumed.
+ */
+ private var pendingNfcMessage: NdefMessage? = null
+ private var nfcStateCallback: ((NfcShareState) -> Unit)? = null
+
+ /** File URI held while waiting for the Wi-Fi Direct permission grant. */
+ private var pendingWifiUri: Uri? = null
+
+ /** Short description of the last failure, included in copied diagnostics. */
+ private var lastErrorDetail: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -56,6 +88,7 @@ class ShareActivity : AppCompatActivity() {
progressBar = findViewById(R.id.progress_bar)
ivIcon = findViewById(R.id.iv_icon)
btnCancel = findViewById(R.id.btn_cancel)
+ btnDebug = findViewById(R.id.btn_debug)
nfcHelper = NfcShareHelper(this)
wifiShare = WifiDirectShare(this)
@@ -64,13 +97,24 @@ class ShareActivity : AppCompatActivity() {
SoundManager.playTap(this)
finish()
}
+ btnDebug.setOnClickListener { copyDiagnosticInfo() }
+ Log.d(TAG, "onCreate – action=${intent?.action} type=${intent?.type}")
handleShareIntent(intent)
}
override fun onResume() {
super.onResume()
wifiShare.register()
+
+ // NFC foreground dispatch MUST be enabled from onResume (NFC API requirement).
+ // shareViaNfc() stores the message; we enable dispatch here.
+ val msg = pendingNfcMessage
+ val cb = nfcStateCallback
+ if (msg != null && cb != null) {
+ Log.d(TAG, "onResume – enabling NFC foreground dispatch")
+ nfcHelper.startSharing(msg, cb)
+ }
}
override fun onPause() {
@@ -79,29 +123,57 @@ class ShareActivity : AppCompatActivity() {
wifiShare.unregister()
}
- /** Forward NFC intents (foreground dispatch) to the NFC helper. */
+ /** Forward NFC tag-discovered intents received via foreground dispatch. */
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == NfcAdapter.ACTION_TAG_DISCOVERED ||
intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED ||
intent.action == NfcAdapter.ACTION_TECH_DISCOVERED
) {
+ Log.d(TAG, "NFC tag discovered")
SoundManager.playBeam(this)
nfcHelper.onNewIntent(intent)
}
}
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray,
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == REQ_WIFI_PERMISSION) {
+ val granted = grantResults.isNotEmpty() &&
+ grantResults[0] == PackageManager.PERMISSION_GRANTED
+ Log.d(TAG, "Wi-Fi permission result: granted=$granted")
+ if (granted) {
+ pendingWifiUri?.let { uri ->
+ pendingWifiUri = null
+ startWifiDirectTransfer(uri)
+ }
+ } else {
+ lastErrorDetail = "Wi-Fi Direct permission denied"
+ setStatus(getString(R.string.status_permission_denied))
+ tvHint.text = getString(R.string.hint_permission_denied)
+ tvHint.visibility = View.VISIBLE
+ showDebugButton()
+ }
+ }
+ }
+
// -------------------------------------------------------------------------
// Intent handling
// -------------------------------------------------------------------------
private fun handleShareIntent(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) {
+ Log.w(TAG, "Unexpected action: ${intent?.action}")
setStatus(getString(R.string.status_unsupported))
return
}
val mimeType = intent.type ?: ""
+ Log.d(TAG, "MIME type: $mimeType")
when {
mimeType == "text/plain" -> handleTextIntent(intent)
@@ -109,6 +181,7 @@ class ShareActivity : AppCompatActivity() {
handleVCardIntent(intent)
mimeType.startsWith("image/") -> handleImageIntent(intent)
else -> {
+ Log.w(TAG, "Unsupported MIME: $mimeType")
setStatus(getString(R.string.status_unsupported))
}
}
@@ -116,6 +189,7 @@ class ShareActivity : AppCompatActivity() {
private fun handleTextIntent(intent: Intent) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: run {
+ Log.w(TAG, "No EXTRA_TEXT in text/plain intent")
setStatus(getString(R.string.status_error))
return
}
@@ -128,7 +202,7 @@ class ShareActivity : AppCompatActivity() {
} else {
NdefContent.PlainText(text)
}
-
+ Log.d(TAG, "Text intent → ${content::class.simpleName}")
shareViaNfc(content)
}
@@ -137,17 +211,21 @@ class ShareActivity : AppCompatActivity() {
val vcard = uri?.let { readTextFromUri(it) }
?: intent.getStringExtra(Intent.EXTRA_TEXT)
?: run {
+ Log.w(TAG, "No vCard data in intent")
setStatus(getString(R.string.status_error))
return
}
+ Log.d(TAG, "vCard intent, length=${vcard.length}")
shareViaNfc(NdefContent.VCard(vcard))
}
private fun handleImageIntent(intent: Intent) {
val uri = getStreamUri(intent) ?: run {
+ Log.w(TAG, "No EXTRA_STREAM in image intent")
setStatus(getString(R.string.status_error))
return
}
+ Log.d(TAG, "Image intent: $uri")
setStatus(getString(R.string.status_scanning_qr))
progressBar.visibility = View.VISIBLE
@@ -155,13 +233,13 @@ class ShareActivity : AppCompatActivity() {
Thread {
val bitmap = loadBitmap(uri)
val qrResult = bitmap?.let { QrScanner.scan(it) }
+ Log.d(TAG, "QR scan result: ${qrResult?.javaClass?.simpleName ?: "null"}")
runOnUiThread {
progressBar.visibility = View.GONE
if (qrResult != null) {
handleQrResult(qrResult)
} else {
- // No QR found – fall back to Wi-Fi Direct
shareViaWifiDirect(uri)
}
}
@@ -174,7 +252,10 @@ class ShareActivity : AppCompatActivity() {
private fun shareViaNfc(content: NdefContent) {
val message: NdefMessage = NdefHelper.fromContent(content) ?: run {
+ Log.e(TAG, "Failed to build NdefMessage for $content")
+ lastErrorDetail = "NDEF encoding failed for ${content::class.simpleName}"
setStatus(getString(R.string.status_error))
+ showDebugButton()
return
}
@@ -183,32 +264,49 @@ class ShareActivity : AppCompatActivity() {
tvHint.text = getString(R.string.hint_tap_device)
tvHint.visibility = View.VISIBLE
- nfcHelper.startSharing(message) { state ->
+ val callback: (NfcShareState) -> Unit = { state ->
runOnUiThread { onNfcState(state) }
}
+ pendingNfcMessage = message
+ nfcStateCallback = callback
+
+ // enableForegroundDispatch() requires the activity to be resumed.
+ // If already resumed (e.g. called after QR scan), start immediately;
+ // otherwise onResume() will pick up pendingNfcMessage and start it.
+ if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+ Log.d(TAG, "Already resumed – starting NFC now")
+ nfcHelper.startSharing(message, callback)
+ } else {
+ Log.d(TAG, "Not yet resumed – NFC will start in onResume")
+ }
}
private fun onNfcState(state: NfcShareState) {
+ Log.d(TAG, "NFC state → $state")
when (state) {
- NfcShareState.Waiting -> {
- setStatus(getString(R.string.status_waiting_nfc))
- }
+ NfcShareState.Waiting -> setStatus(getString(R.string.status_waiting_nfc))
NfcShareState.Writing -> {
setStatus(getString(R.string.status_writing))
progressBar.visibility = View.VISIBLE
}
NfcShareState.Success -> {
progressBar.visibility = View.GONE
+ pendingNfcMessage = null
+ nfcStateCallback = null
setStatus(getString(R.string.status_done))
SoundManager.playSuccess(this)
finishAfterDelay()
}
NfcShareState.Error -> {
progressBar.visibility = View.GONE
+ lastErrorDetail = "NFC tag write failed"
setStatus(getString(R.string.status_error))
SoundManager.playError(this)
+ showDebugButton()
}
NfcShareState.Unsupported -> {
+ pendingNfcMessage = null
+ nfcStateCallback = null
setStatus(getString(R.string.status_nfc_unavailable))
tvHint.text = getString(R.string.hint_nfc_unavailable)
tvHint.visibility = View.VISIBLE
@@ -228,14 +326,29 @@ class ShareActivity : AppCompatActivity() {
)
is QrResult.RawText -> NdefContent.PlainText(result.text)
}
+ Log.d(TAG, "QR → ${content::class.simpleName}")
shareViaNfc(content)
}
// -------------------------------------------------------------------------
- // Wi-Fi Direct sharing (fallback)
+ // Wi-Fi Direct sharing (fallback for images without a QR code)
// -------------------------------------------------------------------------
private fun shareViaWifiDirect(uri: Uri) {
+ if (!hasWifiDirectPermission()) {
+ Log.d(TAG, "Missing Wi-Fi Direct permission – requesting")
+ pendingWifiUri = uri
+ ivIcon.setImageResource(R.drawable.ic_wifi)
+ setStatus(getString(R.string.status_permission_needed))
+ tvHint.text = getString(R.string.hint_permission_wifi)
+ tvHint.visibility = View.VISIBLE
+ requestWifiDirectPermission()
+ return
+ }
+ startWifiDirectTransfer(uri)
+ }
+
+ private fun startWifiDirectTransfer(uri: Uri) {
ivIcon.setImageResource(R.drawable.ic_wifi)
setStatus(getString(R.string.status_discovering_peers))
tvHint.text = getString(R.string.hint_wifi_direct)
@@ -247,6 +360,7 @@ class ShareActivity : AppCompatActivity() {
}
private fun onWifiState(state: WifiShareState, fileUri: Uri) {
+ Log.d(TAG, "Wi-Fi state → $state")
when (state) {
WifiShareState.Discovering -> {
setStatus(getString(R.string.status_discovering_peers))
@@ -257,7 +371,6 @@ class ShareActivity : AppCompatActivity() {
if (state.peers.isEmpty()) {
setStatus(getString(R.string.status_no_peers))
} else {
- // Auto-connect to first peer for simplicity in this iteration
setStatus(getString(R.string.status_connecting))
wifiShare.connectToPeer(state.peers.first()) { s ->
runOnUiThread { onWifiState(s, fileUri) }
@@ -286,18 +399,79 @@ class ShareActivity : AppCompatActivity() {
}
WifiShareState.Unavailable -> {
progressBar.visibility = View.GONE
+ lastErrorDetail = "Wi-Fi Direct unavailable on this device"
setStatus(getString(R.string.status_wifi_unavailable))
SoundManager.playError(this)
+ showDebugButton()
}
is WifiShareState.Error -> {
progressBar.visibility = View.GONE
+ lastErrorDetail = "Wi-Fi Direct: ${state.message}"
+ Log.e(TAG, "Wi-Fi error: ${state.message}")
setStatus(getString(R.string.status_error))
SoundManager.playError(this)
+ showDebugButton()
}
WifiShareState.Receiving -> { /* not used in send flow */ }
}
}
+ // -------------------------------------------------------------------------
+ // Permissions
+ // -------------------------------------------------------------------------
+
+ /**
+ * Wi-Fi Direct peer discovery requires ACCESS_FINE_LOCATION on API < 33
+ * and NEARBY_WIFI_DEVICES on API >= 33. Both are dangerous permissions that
+ * need a runtime grant.
+ */
+ private fun hasWifiDirectPermission(): Boolean {
+ val perm = wifiDirectPermission()
+ return checkSelfPermission(perm) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun requestWifiDirectPermission() {
+ ActivityCompat.requestPermissions(
+ this, arrayOf(wifiDirectPermission()), REQ_WIFI_PERMISSION,
+ )
+ }
+
+ private fun wifiDirectPermission(): String =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.NEARBY_WIFI_DEVICES
+ } else {
+ Manifest.permission.ACCESS_FINE_LOCATION
+ }
+
+ // -------------------------------------------------------------------------
+ // Diagnostics
+ // -------------------------------------------------------------------------
+
+ private fun showDebugButton() {
+ btnDebug.visibility = View.VISIBLE
+ }
+
+ /**
+ * Copies a diagnostic summary to the clipboard so the user can paste it
+ * into a bug report or chat. For full logs run: adb logcat -s OpenBeam
+ */
+ private fun copyDiagnosticInfo() {
+ val info = buildString {
+ appendLine("=== OpenBeam Diagnostics ===")
+ appendLine("Version : ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
+ appendLine("Device : ${Build.MANUFACTURER} ${Build.MODEL}")
+ appendLine("Android : ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
+ appendLine("NFC : available=${nfcHelper.isAvailable}")
+ lastErrorDetail?.let { appendLine("Error : $it") }
+ appendLine()
+ appendLine("Full logs: adb logcat -s $TAG")
+ }
+ val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ cm.setPrimaryClip(ClipData.newPlainText("OpenBeam diagnostics", info))
+ Toast.makeText(this, R.string.debug_info_copied, Toast.LENGTH_SHORT).show()
+ Log.d(TAG, "Diagnostic info copied to clipboard")
+ }
+
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
@@ -310,22 +484,19 @@ class ShareActivity : AppCompatActivity() {
tvStatus.postDelayed({ finish() }, delayMs)
}
- private fun getStreamUri(intent: Intent): Uri? {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ private fun getStreamUri(intent: Intent): Uri? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
}
- }
private fun readTextFromUri(uri: Uri): String? = runCatching {
contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
}.getOrNull()
private fun loadBitmap(uri: Uri): Bitmap? = runCatching {
- contentResolver.openInputStream(uri)?.use { stream ->
- BitmapFactory.decodeStream(stream)
- }
+ contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) }
}.getOrNull()
}
diff --git a/app/src/main/res/layout-land/activity_share.xml b/app/src/main/res/layout-land/activity_share.xml
index 11f7b8d..42cfd93 100644
--- a/app/src/main/res/layout-land/activity_share.xml
+++ b/app/src/main/res/layout-land/activity_share.xml
@@ -82,4 +82,15 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_hint" />
+
+
+
diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml
index aa14558..117f34c 100644
--- a/app/src/main/res/layout/activity_share.xml
+++ b/app/src/main/res/layout/activity_share.xml
@@ -82,4 +82,16 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_hint" />
+
+
+
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 2cdca48..b23e8ab 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -14,15 +14,23 @@
Connectant…
Transferint…
Wi-Fi Direct no disponible
+ Cal un permís
+ Permís denegat
Toca la part del darrere d\'un altre dispositiu per compartir
Activa el NFC a Configuració
Assegura\'t que l\'altre dispositiu també té OpenBeam obert
+ OpenBeam necessita el permís de Dispositius Wi-Fi propers (o Ubicació) per trobar dispositius
+ Ves a Configuració → Aplicacions → OpenBeam → Permisos i concedeix el permís necessari
Cancel·la
+ Copia la info de depuració
Icona del mètode de compartició
+
+
+ Info de diagnòstic copiada — enganxa-la a l\'informe d\'error
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 505b477..225d67b 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -14,15 +14,23 @@
Conectando…
Transfiriendo…
Wi-Fi Direct no disponible
+ Permiso necesario
+ Permiso denegado
Toca la parte trasera de otro dispositivo para compartir
Activa el NFC en Ajustes
Asegúrate de que el otro dispositivo también tiene OpenBeam abierto
+ OpenBeam necesita el permiso de Dispositivos Wi-Fi cercanos (o Ubicación) para encontrar dispositivos
+ Ve a Ajustes → Aplicaciones → OpenBeam → Permisos y concede el permiso necesario
Cancelar
+ Copiar info de depuración
Icono del método de compartición
+
+
+ Info de diagnóstico copiada — pégala en tu informe de error
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8132463..04739c8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -14,15 +14,23 @@
Connecting…
Transferring…
Wi-Fi Direct not available
+ Permission needed
+ Permission denied
Tap the back of another device to share
Please enable NFC in Settings
Make sure the other device also has OpenBeam open
+ OpenBeam needs the Nearby Wi-Fi Devices (or Location) permission to find nearby devices
+ Go to Settings → Apps → OpenBeam → Permissions and grant the required permission
Cancel
+ Copy debug info
Share method icon
+
+
+ Diagnostic info copied — paste it in your bug report
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index f6dbd54..2019c4c 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -2,17 +2,15 @@
-
-
-
-
-
From ad57f9073eb4ee10546105a21d86424ffe3f53f9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:22:39 +0000
Subject: [PATCH 5/6] fix: remove double title, suppress NFC tap sound, block
HCE during sharing
---
.../net/duhowpi/openbeam/ShareActivity.kt | 23 +++--
.../openbeam/sharing/NfcShareHelper.kt | 87 +++++++++----------
.../metadata/android/en-US/changelogs/1.txt | 7 +-
3 files changed, 56 insertions(+), 61 deletions(-)
diff --git a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
index 088e12d..2282157 100644
--- a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
@@ -10,7 +10,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.nfc.NdefMessage
-import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -67,8 +66,8 @@ class ShareActivity : AppCompatActivity() {
/**
* NFC message ready to write, prepared in [handleShareIntent] (called from [onCreate]).
- * Actual foreground dispatch is enabled only in [onResume] because
- * [android.nfc.NfcAdapter.enableForegroundDispatch] requires the activity to be resumed.
+ * Actual reader mode is enabled only in [onResume] because
+ * [android.nfc.NfcAdapter.enableReaderMode] requires the activity to be resumed.
*/
private var pendingNfcMessage: NdefMessage? = null
private var nfcStateCallback: ((NfcShareState) -> Unit)? = null
@@ -81,6 +80,9 @@ class ShareActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ // The Dialog theme may still show an AppCompat action bar as an outer title;
+ // hide it so only the tv_app_name inside the dialog box is visible.
+ supportActionBar?.hide()
setContentView(R.layout.activity_share)
tvStatus = findViewById(R.id.tv_status)
@@ -123,17 +125,11 @@ class ShareActivity : AppCompatActivity() {
wifiShare.unregister()
}
- /** Forward NFC tag-discovered intents received via foreground dispatch. */
+ /** Forward any remaining intents received while activity is on top (singleTop). */
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- if (intent.action == NfcAdapter.ACTION_TAG_DISCOVERED ||
- intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED ||
- intent.action == NfcAdapter.ACTION_TECH_DISCOVERED
- ) {
- Log.d(TAG, "NFC tag discovered")
- SoundManager.playBeam(this)
- nfcHelper.onNewIntent(intent)
- }
+ // NFC is now handled via NfcAdapter.enableReaderMode callback in NfcShareHelper;
+ // no NFC intents are dispatched to the activity while reader mode is active.
}
override fun onRequestPermissionsResult(
@@ -270,7 +266,7 @@ class ShareActivity : AppCompatActivity() {
pendingNfcMessage = message
nfcStateCallback = callback
- // enableForegroundDispatch() requires the activity to be resumed.
+ // enableReaderMode() requires the activity to be resumed.
// If already resumed (e.g. called after QR scan), start immediately;
// otherwise onResume() will pick up pendingNfcMessage and start it.
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
@@ -288,6 +284,7 @@ class ShareActivity : AppCompatActivity() {
NfcShareState.Writing -> {
setStatus(getString(R.string.status_writing))
progressBar.visibility = View.VISIBLE
+ SoundManager.playBeam(this)
}
NfcShareState.Success -> {
progressBar.visibility = View.GONE
diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt
index 2e1b508..420ecce 100644
--- a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt
@@ -1,41 +1,43 @@
package net.duhowpi.openbeam.sharing
import android.app.Activity
-import android.app.PendingIntent
-import android.content.Intent
import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.Ndef
import android.nfc.tech.NdefFormatable
-import android.os.Build
/**
- * Manages NFC foreground dispatch and NDEF tag writing.
+ * Manages NFC reader mode and NDEF tag writing.
*
* Usage:
- * 1. Call [startSharing] with the message to send and a callback.
- * 2. Forward `onNewIntent` to [onNewIntent].
- * 3. Call [stopSharing] in `onPause` or when done.
+ * 1. Call [startSharing] with the message to send and a callback (from onResume).
+ * 2. Call [stopSharing] in onPause or when done.
+ *
+ * Uses [NfcAdapter.enableReaderMode] instead of foreground dispatch so that:
+ * - The platform NFC tap sound is suppressed ([NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS]).
+ * - Host Card Emulation (HCE/payment) is paused while sharing is active, preventing the
+ * device from responding to external payment terminals.
+ * - Other NFC apps are blocked from receiving tags while the activity is in the foreground.
*
* Android Beam (`setNdefPushMessage`) was removed in API 34.
- * This helper uses foreground dispatch instead: it enables the activity to
- * intercept NFC tags while in the foreground and write the NDEF message to them.
*/
class NfcShareHelper(private val activity: Activity) {
private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(activity)
- private var pendingMessage: NdefMessage? = null
- private var onResult: ((NfcShareState) -> Unit)? = null
+
+ @Volatile private var pendingMessage: NdefMessage? = null
+ @Volatile private var onResult: ((NfcShareState) -> Unit)? = null
/** True if NFC hardware is present and enabled on this device. */
val isAvailable: Boolean
get() = adapter?.isEnabled == true
/**
- * Enable NFC foreground dispatch so the activity can catch tag discoveries.
+ * Enable NFC reader mode so the activity can catch tag discoveries.
+ * Must be called from Activity.onResume (NFC API requirement).
* @param message NDEF message to write when a tag is tapped.
- * @param onResult Callback invoked with the result state.
+ * @param onResult Callback invoked with the result state (may be called from a background thread).
*/
fun startSharing(message: NdefMessage, onResult: (NfcShareState) -> Unit) {
if (!isAvailable) {
@@ -44,58 +46,49 @@ class NfcShareHelper(private val activity: Activity) {
}
this.pendingMessage = message
this.onResult = onResult
- enableForegroundDispatch()
+ enableReaderMode()
onResult(NfcShareState.Waiting)
}
- /** Disable foreground dispatch. Call from Activity.onPause. */
+ /** Disable reader mode. Call from Activity.onPause. */
fun stopSharing() {
- adapter?.disableForegroundDispatch(activity)
+ adapter?.disableReaderMode(activity)
pendingMessage = null
onResult = null
}
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private fun enableReaderMode() {
+ // Listen for all common NFC tag technologies.
+ // FLAG_READER_NO_PLATFORM_SOUNDS suppresses the OS tap sound when any tag is detected,
+ // so non-NDEF tags (e.g. EMV payment cards) are silently ignored.
+ val flags = NfcAdapter.FLAG_READER_NFC_A or
+ NfcAdapter.FLAG_READER_NFC_B or
+ NfcAdapter.FLAG_READER_NFC_F or
+ NfcAdapter.FLAG_READER_NFC_V or
+ NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS
+ adapter?.enableReaderMode(activity, ::onTagDiscovered, flags, null)
+ }
+
/**
- * Must be called from Activity.onNewIntent.
- * Handles NFC tag discovery and writes the pending NDEF message.
+ * Called by the NFC subsystem on a background thread when a tag is detected.
+ * Non-NDEF tags (e.g. EMV payment cards) are silently ignored so no error is shown.
*/
- fun onNewIntent(intent: Intent) {
- val action = intent.action ?: return
- if (action != NfcAdapter.ACTION_TAG_DISCOVERED &&
- action != NfcAdapter.ACTION_NDEF_DISCOVERED &&
- action != NfcAdapter.ACTION_TECH_DISCOVERED
- ) return
-
- val tag: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
- } ?: return
-
+ private fun onTagDiscovered(tag: Tag) {
val message = pendingMessage ?: return
val callback = onResult ?: return
+ // Ignore tags that support neither NDEF nor NdefFormatable (e.g. payment cards).
+ if (Ndef.get(tag) == null && NdefFormatable.get(tag) == null) return
+
callback(NfcShareState.Writing)
val success = writeNdef(tag, message)
callback(if (success) NfcShareState.Success else NfcShareState.Error)
}
- // -------------------------------------------------------------------------
- // Private helpers
- // -------------------------------------------------------------------------
-
- private fun enableForegroundDispatch() {
- val intent = Intent(activity, activity.javaClass).apply {
- addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
- }
- val pendingIntent = PendingIntent.getActivity(
- activity, 0, intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
- )
- adapter?.enableForegroundDispatch(activity, pendingIntent, null, null)
- }
-
/** Write [message] to [tag]. Returns true on success. */
private fun writeNdef(tag: Tag, message: NdefMessage): Boolean {
// Try NDEF first (tag already formatted)
diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt
index 0c3d035..50d59ef 100644
--- a/fastlane/metadata/android/en-US/changelogs/1.txt
+++ b/fastlane/metadata/android/en-US/changelogs/1.txt
@@ -3,4 +3,9 @@ Initial release.
- Share URLs, vCards and plain text via NFC tag write
- Scan images for QR codes and beam the decoded content (URLs, Wi-Fi credentials) via NFC
- Wi-Fi Direct fallback when image has no QR code or NFC is unavailable
-- Supports English, Spanish and Catalan
\ No newline at end of file
+- Supports English, Spanish and Catalan
+
+Fixes:
+- Removed duplicate app title shown outside the share dialog
+- NFC tap sound no longer plays when a non-writable tag (e.g. a payment card) is nearby during sharing
+- Payment card emulation (HCE) is now paused while sharing is active, blocking unintended NFC payment reads
\ No newline at end of file
From cba5f1e80054346bca62510607c91fa64f7b9147 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:31:34 +0000
Subject: [PATCH 6/6] fix: suppress outer dialog window title with
FEATURE_NO_TITLE
---
app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
index 2282157..d81e131 100644
--- a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
+++ b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt
@@ -14,6 +14,7 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
+import android.view.Window
import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
@@ -79,10 +80,10 @@ class ShareActivity : AppCompatActivity() {
private var lastErrorDetail: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
+ // Must be called before super.onCreate() to suppress the window title bar that the
+ // Dialog theme renders outside/above the dialog box.
+ supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
super.onCreate(savedInstanceState)
- // The Dialog theme may still show an AppCompat action bar as an outer title;
- // hide it so only the tv_app_name inside the dialog box is visible.
- supportActionBar?.hide()
setContentView(R.layout.activity_share)
tvStatus = findViewById(R.id.tv_status)