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

- - Build + + Build Android 8.0+ Kotlin - -

- - Get it on Obtainium - - - + Download APK @@ -28,22 +21,56 @@ --- -Click the button. [![Use this template](https://img.shields.io/badge/Use%20this%20template-green)](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 @@ + + + + + + + + + + + + + + + + + + + + +