Initial commit: Android Tether API app

Exposes phone connection status via HTTP API for tethered devices.

Features:
- Auto-start/stop server when tethering is enabled/disabled
- REST API with /status and /health endpoints
- Connection type, signal strength, carrier, battery info
- Foreground service with persistent notification

Stack: Kotlin 2.3, AGP 8.13, Gradle 8.13, compileSdk 36
This commit is contained in:
2025-12-18 21:08:56 +01:00
commit 017172f48a
27 changed files with 1809 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TetherAPI"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.TetherAPI">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.TetherApiService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<receiver
android:name=".receiver.TetherStateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.net.conn.TETHER_STATE_CHANGED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,210 @@
package dev.itsh.tetherapi
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.google.gson.GsonBuilder
import dev.itsh.tetherapi.databinding.ActivityMainBinding
import dev.itsh.tetherapi.provider.StatusProvider
import dev.itsh.tetherapi.receiver.TetherStateReceiver
import dev.itsh.tetherapi.service.TetherApiService
import java.net.NetworkInterface
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val handler = Handler(Looper.getMainLooper())
private val statusProvider by lazy { StatusProvider(this) }
private val gson = GsonBuilder().setPrettyPrinting().create()
private val requiredPermissions = buildList {
add(Manifest.permission.READ_PHONE_STATE)
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.all { it.value }
if (allGranted) {
updateUI()
} else {
showPermissionRationale()
}
}
private val updateRunnable = object : Runnable {
override fun run() {
updateUI()
handler.postDelayed(this, UPDATE_INTERVAL)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupUI()
loadSettings()
checkPermissions()
}
override fun onResume() {
super.onResume()
handler.post(updateRunnable)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(updateRunnable)
}
private fun setupUI() {
binding.toggleButton.setOnClickListener {
if (TetherApiService.isRunning) {
TetherApiService.stop(this)
} else {
val port = binding.portInput.text.toString().toIntOrNull() ?: TetherApiService.DEFAULT_PORT
TetherApiService.start(this, port)
}
handler.postDelayed({ updateUI() }, 500)
}
binding.autoStartSwitch.setOnCheckedChangeListener { _, _ ->
saveSettings()
}
binding.portInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
saveSettings()
}
}
}
private fun loadSettings() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
binding.autoStartSwitch.isChecked = prefs.getBoolean(TetherStateReceiver.PREF_AUTO_START, true)
binding.portInput.setText(prefs.getInt(TetherStateReceiver.PREF_PORT, TetherApiService.DEFAULT_PORT).toString())
}
private fun saveSettings() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val port = binding.portInput.text.toString().toIntOrNull() ?: TetherApiService.DEFAULT_PORT
prefs.edit()
.putBoolean(TetherStateReceiver.PREF_AUTO_START, binding.autoStartSwitch.isChecked)
.putInt(TetherStateReceiver.PREF_PORT, port)
.apply()
}
private fun checkPermissions() {
val missingPermissions = requiredPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isNotEmpty()) {
permissionLauncher.launch(missingPermissions.toTypedArray())
}
}
private fun showPermissionRationale() {
AlertDialog.Builder(this)
.setTitle("Permissions Required")
.setMessage(
"This app needs the following permissions:\n\n" +
"• Phone State: To read network connection type and carrier\n" +
"• Location: Required by Android to access cell signal info\n" +
"• Notifications: To show service status"
)
.setPositiveButton("Grant") { _, _ ->
permissionLauncher.launch(requiredPermissions.toTypedArray())
}
.setNegativeButton("Cancel", null)
.show()
}
private fun updateUI() {
val isRunning = TetherApiService.isRunning
val port = TetherApiService.currentPort ?: binding.portInput.text.toString().toIntOrNull() ?: TetherApiService.DEFAULT_PORT
binding.statusText.text = if (isRunning) {
getString(R.string.status_running)
} else {
getString(R.string.status_stopped)
}
val statusColor = if (isRunning) {
ContextCompat.getColor(this, R.color.status_running)
} else {
ContextCompat.getColor(this, R.color.status_stopped)
}
(binding.statusIndicator.background as? GradientDrawable)?.setColor(statusColor)
binding.toggleButton.text = if (isRunning) {
getString(R.string.btn_stop)
} else {
getString(R.string.btn_start)
}
val gatewayIp = getGatewayIp()
binding.endpointText.text = if (gatewayIp != null) {
"http://$gatewayIp:$port/status"
} else {
"http://[phone-ip]:$port/status"
}
updateStatusPreview()
}
private fun updateStatusPreview() {
try {
val status = statusProvider.getStatus()
binding.previewText.text = gson.toJson(status)
} catch (e: Exception) {
binding.previewText.text = "Error: ${e.message}"
}
}
private fun getGatewayIp(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
val name = networkInterface.name.lowercase()
if (name.startsWith("rndis") || name.startsWith("usb") ||
name.startsWith("wlan") || name.startsWith("ap") ||
name.startsWith("swlan")) {
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (!address.isLoopbackAddress && address.hostAddress?.contains(".") == true) {
return address.hostAddress
}
}
}
}
} catch (e: Exception) {
// Ignore
}
return null
}
companion object {
private const val UPDATE_INTERVAL = 2000L
}
}

View File

@@ -0,0 +1,88 @@
package dev.itsh.tetherapi.api
import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dev.itsh.tetherapi.provider.StatusProvider
import fi.iki.elonen.NanoHTTPD
class ApiServer(
private val context: Context,
port: Int
) : NanoHTTPD("0.0.0.0", port) {
private val statusProvider = StatusProvider(context)
private val gson: Gson = GsonBuilder()
.setPrettyPrinting()
.create()
override fun serve(session: IHTTPSession): Response {
val uri = session.uri
val method = session.method
if (method != Method.GET) {
return newFixedLengthResponse(
Response.Status.METHOD_NOT_ALLOWED,
MIME_JSON,
"""{"error": "Method not allowed"}"""
).apply {
addHeader("Access-Control-Allow-Origin", "*")
}
}
return when (uri) {
"/status" -> handleStatus()
"/health" -> handleHealth()
"/" -> handleRoot()
else -> handleNotFound()
}
}
private fun handleStatus(): Response {
return try {
val status = statusProvider.getStatus()
val json = gson.toJson(status)
jsonResponse(Response.Status.OK, json)
} catch (e: Exception) {
jsonResponse(
Response.Status.INTERNAL_ERROR,
"""{"error": "${e.message ?: "Unknown error"}"}"""
)
}
}
private fun handleHealth(): Response {
return jsonResponse(Response.Status.OK, """{"status": "ok"}""")
}
private fun handleRoot(): Response {
val info = mapOf(
"name" to "Tether API",
"version" to "1.0.0",
"endpoints" to listOf(
mapOf("path" to "/status", "method" to "GET", "description" to "Get phone status"),
mapOf("path" to "/health", "method" to "GET", "description" to "Health check")
)
)
return jsonResponse(Response.Status.OK, gson.toJson(info))
}
private fun handleNotFound(): Response {
return jsonResponse(
Response.Status.NOT_FOUND,
"""{"error": "Not found"}"""
)
}
private fun jsonResponse(status: Response.Status, json: String): Response {
return newFixedLengthResponse(status, MIME_JSON, json).apply {
addHeader("Access-Control-Allow-Origin", "*")
addHeader("Access-Control-Allow-Methods", "GET")
addHeader("Cache-Control", "no-cache")
}
}
companion object {
private const val MIME_JSON = "application/json"
}
}

View File

@@ -0,0 +1,33 @@
package dev.itsh.tetherapi.data
import com.google.gson.annotations.SerializedName
data class PhoneStatus(
val connection: ConnectionInfo,
val battery: BatteryInfo,
val network: NetworkInfo,
val timestamp: Long
)
data class ConnectionInfo(
val type: String,
@SerializedName("signal_dbm")
val signalDbm: Int,
@SerializedName("signal_bars")
val signalBars: Int,
val carrier: String,
val operator: String,
val roaming: Boolean
)
data class BatteryInfo(
val level: Int,
val charging: Boolean
)
data class NetworkInfo(
@SerializedName("wifi_ssid")
val wifiSsid: String?,
@SerializedName("ip_addresses")
val ipAddresses: List<String>
)

View File

@@ -0,0 +1,255 @@
package dev.itsh.tetherapi.provider
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.os.BatteryManager
import android.os.Build
import android.telephony.CellInfo
import android.telephony.CellInfoLte
import android.telephony.CellInfoNr
import android.telephony.CellInfoGsm
import android.telephony.CellInfoWcdma
import android.telephony.CellInfoCdma
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import dev.itsh.tetherapi.data.BatteryInfo
import dev.itsh.tetherapi.data.ConnectionInfo
import dev.itsh.tetherapi.data.NetworkInfo
import dev.itsh.tetherapi.data.PhoneStatus
import java.net.NetworkInterface
class StatusProvider(private val context: Context) {
private val telephonyManager: TelephonyManager by lazy {
context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
}
private val connectivityManager: ConnectivityManager by lazy {
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
private val wifiManager: WifiManager by lazy {
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
}
fun getStatus(): PhoneStatus {
return PhoneStatus(
connection = getConnectionInfo(),
battery = getBatteryInfo(),
network = getNetworkInfo(),
timestamp = System.currentTimeMillis() / 1000
)
}
private fun getConnectionInfo(): ConnectionInfo {
val hasPhonePermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
val hasLocationPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val connectionType = getConnectionType()
val (signalDbm, signalBars) = if (hasLocationPermission) {
getSignalStrength()
} else {
Pair(-999, 0)
}
val carrier = if (hasPhonePermission) {
telephonyManager.networkOperatorName ?: "Unknown"
} else {
"Unknown"
}
val operator = telephonyManager.networkOperator ?: ""
val isRoaming = try {
telephonyManager.isNetworkRoaming
} catch (e: Exception) {
false
}
return ConnectionInfo(
type = connectionType,
signalDbm = signalDbm,
signalBars = signalBars,
carrier = carrier,
operator = operator,
roaming = isRoaming
)
}
private fun getConnectionType(): String {
val network = connectivityManager.activeNetwork ?: return "NONE"
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "NONE"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> getCellularType()
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ETHERNET"
else -> "UNKNOWN"
}
}
private fun getCellularType(): String {
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
if (!hasPermission) return "CELLULAR"
return when (telephonyManager.dataNetworkType) {
TelephonyManager.NETWORK_TYPE_NR -> "5G"
TelephonyManager.NETWORK_TYPE_LTE -> "LTE"
TelephonyManager.NETWORK_TYPE_HSPAP,
TelephonyManager.NETWORK_TYPE_HSPA,
TelephonyManager.NETWORK_TYPE_HSDPA,
TelephonyManager.NETWORK_TYPE_HSUPA -> "HSPA"
TelephonyManager.NETWORK_TYPE_UMTS,
TelephonyManager.NETWORK_TYPE_EVDO_0,
TelephonyManager.NETWORK_TYPE_EVDO_A,
TelephonyManager.NETWORK_TYPE_EVDO_B -> "3G"
TelephonyManager.NETWORK_TYPE_EDGE,
TelephonyManager.NETWORK_TYPE_GPRS,
TelephonyManager.NETWORK_TYPE_CDMA,
TelephonyManager.NETWORK_TYPE_1xRTT -> "2G"
TelephonyManager.NETWORK_TYPE_UNKNOWN -> "CELLULAR"
else -> "CELLULAR"
}
}
@Suppress("DEPRECATION")
private fun getSignalStrength(): Pair<Int, Int> {
val hasLocationPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!hasLocationPermission) {
return Pair(-999, 0)
}
try {
val cellInfoList: List<CellInfo>? = telephonyManager.allCellInfo
if (cellInfoList.isNullOrEmpty()) {
return Pair(-999, 0)
}
for (cellInfo in cellInfoList) {
if (!cellInfo.isRegistered) continue
val dbm = when (cellInfo) {
is CellInfoLte -> cellInfo.cellSignalStrength.dbm
is CellInfoNr -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cellInfo.cellSignalStrength.dbm
} else -999
is CellInfoGsm -> cellInfo.cellSignalStrength.dbm
is CellInfoWcdma -> cellInfo.cellSignalStrength.dbm
is CellInfoCdma -> cellInfo.cellSignalStrength.dbm
else -> continue
}
val level = when (cellInfo) {
is CellInfoLte -> cellInfo.cellSignalStrength.level
is CellInfoNr -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cellInfo.cellSignalStrength.level
} else 0
is CellInfoGsm -> cellInfo.cellSignalStrength.level
is CellInfoWcdma -> cellInfo.cellSignalStrength.level
is CellInfoCdma -> cellInfo.cellSignalStrength.level
else -> continue
}
return Pair(dbm, level)
}
} catch (e: SecurityException) {
return Pair(-999, 0)
} catch (e: Exception) {
return Pair(-999, 0)
}
return Pair(-999, 0)
}
private fun getBatteryInfo(): BatteryInfo {
val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { filter ->
context.registerReceiver(null, filter)
}
val level = batteryStatus?.let { intent ->
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
if (level >= 0 && scale > 0) {
(level * 100 / scale)
} else {
-1
}
} ?: -1
val isCharging = batteryStatus?.let { intent ->
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
} ?: false
return BatteryInfo(
level = level,
charging = isCharging
)
}
@Suppress("DEPRECATION")
private fun getNetworkInfo(): NetworkInfo {
val wifiSsid = try {
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
if (capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
val wifiInfo = wifiManager.connectionInfo
wifiInfo?.ssid?.replace("\"", "")?.takeIf { it != "<unknown ssid>" }
} else {
null
}
} catch (e: Exception) {
null
}
val ipAddresses = getIpAddresses()
return NetworkInfo(
wifiSsid = wifiSsid,
ipAddresses = ipAddresses
)
}
private fun getIpAddresses(): List<String> {
val addresses = mutableListOf<String>()
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
if (networkInterface.isLoopback || !networkInterface.isUp) continue
val inetAddresses = networkInterface.inetAddresses
while (inetAddresses.hasMoreElements()) {
val address = inetAddresses.nextElement()
if (!address.isLoopbackAddress && !address.hostAddress.isNullOrEmpty()) {
val hostAddress = address.hostAddress!!
if (!hostAddress.contains(":")) {
addresses.add(hostAddress)
}
}
}
}
} catch (e: Exception) {
// Ignore
}
return addresses
}
}

View File

@@ -0,0 +1,61 @@
package dev.itsh.tetherapi.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.os.Build
import androidx.preference.PreferenceManager
import dev.itsh.tetherapi.service.TetherApiService
class TetherStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_TETHER_STATE_CHANGED) return
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val autoStartEnabled = prefs.getBoolean(PREF_AUTO_START, true)
if (!autoStartEnabled) return
val isTethering = isTetherActive(context, intent)
val port = prefs.getInt(PREF_PORT, TetherApiService.DEFAULT_PORT)
if (isTethering && !TetherApiService.isRunning) {
TetherApiService.start(context, port)
} else if (!isTethering && TetherApiService.isRunning) {
TetherApiService.stop(context)
}
}
@Suppress("DEPRECATION")
private fun isTetherActive(context: Context, intent: Intent): Boolean {
val extras = intent.extras ?: return false
val activeList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getStringArrayList("activeArray")
} else {
@Suppress("UNCHECKED_CAST")
extras.get("activeArray") as? ArrayList<String>
}
if (!activeList.isNullOrEmpty()) {
return true
}
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val method = cm.javaClass.getDeclaredMethod("getTetheredIfaces")
val tetheredIfaces = method.invoke(cm) as? Array<*>
tetheredIfaces != null && tetheredIfaces.isNotEmpty()
} catch (e: Exception) {
false
}
}
companion object {
const val ACTION_TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED"
const val PREF_AUTO_START = "auto_start_on_tether"
const val PREF_PORT = "api_port"
}
}

View File

@@ -0,0 +1,163 @@
package dev.itsh.tetherapi.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import dev.itsh.tetherapi.MainActivity
import dev.itsh.tetherapi.R
import dev.itsh.tetherapi.api.ApiServer
class TetherApiService : Service() {
private var apiServer: ApiServer? = null
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): TetherApiService = this@TetherApiService
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val port = intent?.getIntExtra(EXTRA_PORT, DEFAULT_PORT) ?: DEFAULT_PORT
when (intent?.action) {
ACTION_START -> startServer(port)
ACTION_STOP -> stopServer()
}
return START_STICKY
}
private fun startServer(port: Int) {
if (apiServer?.isAlive == true) {
return
}
try {
apiServer = ApiServer(this, port).apply {
start()
}
val notification = createNotification(port)
startForeground(NOTIFICATION_ID, notification)
isRunning = true
currentPort = port
} catch (e: Exception) {
stopSelf()
}
}
private fun stopServer() {
apiServer?.stop()
apiServer = null
isRunning = false
currentPort = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
stopServer()
super.onDestroy()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.service_notification_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows when Tether API server is running"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
private fun createNotification(port: Int): Notification {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val stopIntent = Intent(this, TetherApiService::class.java).apply {
action = ACTION_STOP
}
val stopPendingIntent = PendingIntent.getService(
this,
1,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.service_notification_title))
.setContentText(getString(R.string.service_notification_text, port))
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.addAction(0, "Stop", stopPendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
companion object {
const val ACTION_START = "dev.itsh.tetherapi.action.START"
const val ACTION_STOP = "dev.itsh.tetherapi.action.STOP"
const val EXTRA_PORT = "dev.itsh.tetherapi.extra.PORT"
const val DEFAULT_PORT = 8765
private const val CHANNEL_ID = "tether_api_service"
private const val NOTIFICATION_ID = 1
var isRunning = false
private set
var currentPort: Int? = null
private set
fun start(context: Context, port: Int = DEFAULT_PORT) {
val intent = Intent(context, TetherApiService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_PORT, port)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, TetherApiService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Signal bars icon centered in adaptive icon safe zone -->
<group
android:translateX="27"
android:translateY="27">
<!-- Bar 1 (shortest) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M4,44 L12,44 L12,54 L4,54 Z" />
<!-- Bar 2 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M16,34 L24,34 L24,54 L16,54 Z" />
<!-- Bar 3 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M28,24 L36,24 L36,54 L28,54 Z" />
<!-- Bar 4 (tallest) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M40,14 L48,14 L48,54 L40,54 Z" />
<!-- API text -->
<path
android:fillColor="#FFFFFF"
android:pathData="M4,4 L10,4 L14,12 L18,4 L24,4 L24,6 L20,6 L16,14 L12,6 L8,6 L8,4 Z" />
</group>
</vector>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Signal bars -->
<path
android:fillColor="#FFFFFF"
android:pathData="M2,20 L4,20 L4,16 L2,16 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M6,20 L8,20 L8,12 L6,12 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M10,20 L12,20 L12,8 L10,8 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14,20 L16,20 L16,4 L14,4 Z" />
<!-- API indicator -->
<path
android:fillColor="#FFFFFF"
android:pathData="M18,8 L22,8 L22,20 L20,20 L20,15 L18,15 L18,20 L18,8 M20,10 L20,13 L20,10 Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/status_stopped" />
</shape>

View File

@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<TextView
android:id="@+id/titleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/statusCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:id="@+id/statusIndicator"
android:layout_width="12dp"
android:layout_height="12dp"
android:background="@drawable/status_indicator" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/status_stopped"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/endpointLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/label_endpoint"
android:textColor="?android:textColorSecondary"
android:textSize="12sp" />
<TextView
android:id="@+id/endpointText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textIsSelectable="true"
android:textSize="14sp"
tools:text="http://192.168.42.1:8765/status" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/toggleButton"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="24dp"
android:text="@string/btn_start"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusCard" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/settingsCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toggleButton">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/label_port"
android:textSize="16sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/portInput"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:gravity="center"
android:inputType="number"
android:maxLength="5"
android:text="8765" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoStartSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="true"
android:text="@string/label_auto_start" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/previewTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Current Status Preview"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/settingsCard" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/previewTitle">
<TextView
android:id="@+id/previewText"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="#F5F5F5"
android:fontFamily="monospace"
android:padding="12dp"
android:textIsSelectable="true"
android:textSize="11sp"
tools:text='{"connection": {...}}' />
</HorizontalScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1976D2</color>
<color name="primary_variant">#1565C0</color>
<color name="secondary">#26A69A</color>
<color name="background">#FAFAFA</color>
<color name="surface">#FFFFFF</color>
<color name="error">#B00020</color>
<color name="on_primary">#FFFFFF</color>
<color name="on_secondary">#000000</color>
<color name="on_background">#000000</color>
<color name="on_surface">#000000</color>
<color name="on_error">#FFFFFF</color>
<color name="status_running">#4CAF50</color>
<color name="status_stopped">#9E9E9E</color>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tether API</string>
<string name="service_notification_channel">Tether API Service</string>
<string name="service_notification_title">Tether API Running</string>
<string name="service_notification_text">API server active on port %d</string>
<string name="status_running">Running</string>
<string name="status_stopped">Stopped</string>
<string name="btn_start">Start Server</string>
<string name="btn_stop">Stop Server</string>
<string name="label_endpoint">API Endpoint</string>
<string name="label_port">Port</string>
<string name="label_auto_start">Auto-start on tether</string>
<string name="permission_rationale_location">Location permission is required to access cell signal information on Android 10+</string>
<string name="permission_rationale_phone">Phone state permission is required to read network connection information</string>
<string name="permission_rationale_notification">Notification permission allows showing the service status</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TetherAPI" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_variant</item>
<item name="colorSecondary">@color/secondary</item>
<item name="android:colorBackground">@color/background</item>
<item name="colorSurface">@color/surface</item>
<item name="colorError">@color/error</item>
<item name="colorOnPrimary">@color/on_primary</item>
<item name="colorOnSecondary">@color/on_secondary</item>
<item name="colorOnBackground">@color/on_background</item>
<item name="colorOnSurface">@color/on_surface</item>
<item name="colorOnError">@color/on_error</item>
</style>
</resources>