This document describes all the custom code added on top of the original Home Assistant Android app to implement a Firebase-based login system with automated WebView login injection.
Splash (WelcomeFragment)
→ Location permission check
→ LoginFragment (Firebase email/password login)
→ Fetches user data from Firestore (email, subscription expiry)
→ Fetches server passwords from "serverPasswords" subcollection
→ If multiple servers → shows ServerListChooser dialog
→ Fetches server details (externalUrl, internalUrl) from "servers" collection
→ Validates subscription via server time API
→ Decrypts password using AES-256-CBC
→ Stores credentials in HassioUserSession singleton
→ ManualSetupView (auto-fills externalUrl and auto-clicks connect)
→ AuthenticationFragment (WebView auto-login injection)
→ JavaScript injects username/password into HA login form
→ Auto-submits, shows loading overlay
→ MobileAppIntegrationFragment
→ Auto-adds WiFi SSID + internalUrl to all servers
→ WebViewActivity
→ Also triggers autoAddCurrentWifiToAllServers if internalUrl is set
Path: app/src/main/java/io/homeassistant/companion/android/onboarding/login/LoginFragment.kt
package io.homeassistant.companion.android.onboarding.login
import ServerTimeFetchService
import ServerTimeFetchServiceImpl
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import android.util.Patterns
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.DocumentSnapshot
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.onboarding.discovery.DiscoveryFragment
import io.homeassistant.companion.android.onboarding.manual.ManualSetupFragment
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
class LoginFragment : Fragment() {
private var isLoading by mutableStateOf(false)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return ComposeView(requireContext()).apply {
setContent {
HomeAssistantAppTheme {
LoginView(
onLoginClick = { username, password -> loginUserWithFirebase(username, password)},
isLoading = isLoading
)
}
}
}
}
private fun loginUserWithFirebase(username: String, password: String) {
if (validateCredentials(username, password)) {
Log.d("Firestore", "loginUserWithFirebase")
isLoading = true
val auth = FirebaseAuth.getInstance()
val serverTimeService: ServerTimeFetchService = ServerTimeFetchServiceImpl()
// Use coroutines to handle Firebase calls
CoroutineScope(Dispatchers.IO).launch {
try {
auth.signInWithEmailAndPassword(username, password).await()
Log.d("Firestore", "SignInSuccess2")
val userId = auth.currentUser?.uid
Log.d("Firestore", "user id ${userId}")
isLoading = false
if (userId != null) {
Log.d("Firestore", "calling getWebViewCredentials")
// Fetch the external URL, webview username, and password from Firebase
val webviewCredentials = getWebViewCredentials(userId)
Log.d("Firestore", "result getWebViewCredentials ${webviewCredentials?.expirationDate}")
if (webviewCredentials != null) {
val currentTime = serverTimeService.fetchServerTime()
if (currentTime == null) {
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "Failed to fetch server time", Toast.LENGTH_LONG).show()
}
return@launch
}
// Check expiration date
if (webviewCredentials.expirationDate == null || webviewCredentials.expirationDate < currentTime) {
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "You don't have a subscription", Toast.LENGTH_LONG).show()
}
FirebaseAuth.getInstance().signOut()
return@launch
}
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "Login successful", Toast.LENGTH_SHORT).show()
}
Log.d("Firestore", "External URL: ${webviewCredentials.externalUrl}")
Log.d("Firestore", "Webview Username: ${webviewCredentials.username}")
Log.d("Firestore", "Webview Password: ${webviewCredentials.password}")
// Save credentials to UserSession
HassioUserSession.externalUrl = webviewCredentials.externalUrl
HassioUserSession.internalUrl = webviewCredentials.internalUrl
HassioUserSession.webviewUsername = webviewCredentials.username
if(webviewCredentials.password.isNullOrEmpty()){
Log.e("LoginFragment", "webviewCredentials Password is null or empty")
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "WebCred Pass is Empty", Toast.LENGTH_SHORT).show()
}
return@launch
}
try {
// Create an instance of CryptoUtil
val cryptoUtil = CryptoUtil()
// Attempt to decrypt the password
val decryptedPassword = cryptoUtil.aes256CbcPkcs7Decrypt(
webviewCredentials.password.toString()
)
// Save decrypted password to the session
HassioUserSession.webviewPassword = decryptedPassword
} catch (e: Exception) {
// Log error and show a Toast message for decryption failure
Log.e("CryptoUtil", "Password decryption failed: ${e.message}")
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "Password decryption failed", Toast.LENGTH_LONG).show()
}
return@launch // Exit if decryption fails
}
loginNavigation() // Proceed with the next step
} else {
Log.d("Firestore", "No webview credentials found for this user.")
}
} else {
Log.d("FirebaseAuth", "User ID is null after login.")
}
} catch (e: Exception) {
isLoading = false
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "Authentication failed: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun loginNavigation() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && HassioUserSession.externalUrl == null) {
parentFragmentManager
.beginTransaction()
.replace(R.id.content, DiscoveryFragment::class.java, null)
.addToBackStack("Welcome")
.commit()
} else {
parentFragmentManager
.beginTransaction()
.replace(R.id.content, ManualSetupFragment::class.java, null)
.addToBackStack("Welcome")
.commit()
}
}
private fun validateCredentials(username: String, password: String): Boolean {
return when {
username.isEmpty() -> {
Toast.makeText(requireContext(), "Email cannot be empty", Toast.LENGTH_LONG).show()
false
}
password.isEmpty() -> {
Toast.makeText(requireContext(), "Password cannot be empty", Toast.LENGTH_LONG).show()
false
}
!Patterns.EMAIL_ADDRESS.matcher(username).matches() -> {
Toast.makeText(requireContext(), "Invalid email format", Toast.LENGTH_LONG).show()
false
}
else -> true
}
}
data class WebviewCredentials(
val externalUrl: String?,
val internalUrl: String?,
val username: String?,
val password: String?,
val expirationDate: Long?
)
private suspend fun getWebViewCredentials(userId: String): WebviewCredentials? {
val db = FirebaseFirestore.getInstance()
Log.d("Firestore", "getWebViewCredentials init db")
return try {
val userDoc = db.collection("users").document(userId).get().await()
if (userDoc.exists()) {
// Get the email as the username
val webviewUsername = userDoc.getString("email")
// Get the expiration date from subscription.expiresAt
val expirationDate = userDoc.getTimestamp("subscription.expiresAt")?.toDate()?.time
var webviewPassword: String? = null
var externalUrl: String? = null
var internalUrl: String? = null
// Access the serverPasswords subcollection and get the first document
val serverPasswordsCollection = userDoc.reference.collection("serverPasswords")
val serverPasswordsSnapshot = serverPasswordsCollection.get().await()
Log.d("Firestore", "serverpass size: ${serverPasswordsSnapshot.size()}")
var firstServerPasswordDoc: DocumentSnapshot?
if (serverPasswordsSnapshot.documents.size > 1) {
Log.d("Firestore", "Multiple serverPasswords found for user: $userId")
val selectedDoc = ServerListChooser.showServerSelectionDialog(requireContext(), serverPasswordsSnapshot.documents)
firstServerPasswordDoc = selectedDoc
} else {
firstServerPasswordDoc = serverPasswordsSnapshot.documents.firstOrNull()
}
if (firstServerPasswordDoc != null) {
// Get the encrypted password
webviewPassword = firstServerPasswordDoc.getString("encryptedPass")
// Get the server ID (document ID)
val serverId = firstServerPasswordDoc.id
Log.d("Firestore", "First Server DOC: $serverId")
// Access the servers collection to get the externalUrl
val serverDoc = db.collection("servers").document(serverId).get().await()
if (serverDoc.exists()) {
externalUrl = serverDoc.getString("externalUrl")
internalUrl = serverDoc.getString("internalUrl")
// After retrieving externalUrl from serverDoc
if (externalUrl != null && !externalUrl.startsWith("http://") && !externalUrl.startsWith("https://")) {
externalUrl = "https://$externalUrl"
}
if (internalUrl != null && !internalUrl.startsWith("http://") && !internalUrl.startsWith("https://")) {
internalUrl = "http://$internalUrl"
}
} else {
Log.d("Firestore", "No server found with ID: $serverId")
}
} else {
Log.d("Firestore", "No serverPasswords documents for user: $userId")
}
Log.d(
"Firestore:LoginFragment",
"webviewPassword: $webviewPassword, expirationDate: $expirationDate, externalUrl: $externalUrl"
)
WebviewCredentials(externalUrl, internalUrl, webviewUsername, webviewPassword, expirationDate)
} else {
Log.d("Firestore", "No such document.")
null
}
} catch (e: Exception) {
Log.e("Firestore", "Error getting document: ", e)
null
}
}
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/login/LoginView.kt
package io.homeassistant.companion.android.onboarding.login
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.homeassistant.companion.android.R
@Preview
@Composable
fun LoginView(
onLoginClick: (String, String) -> Unit = { _, _ -> },
isLoading: Boolean = false
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
Surface {
Column(modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.TopCenter) {
Image(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.44f),
painter = painterResource(id = R.drawable.login_bg_shape),
contentDescription = null,
contentScale = ContentScale.FillBounds,
)
Row(
modifier = Modifier.padding(top = 55.dp),
verticalAlignment = Alignment.CenterVertically,
){
Icon(
tint = Color.White,
modifier = Modifier.size(100.dp),
painter = painterResource(id = R.drawable.my_smart_home_icon), contentDescription = null)
}
Text(
style = MaterialTheme.typography.h5.copy(
color = colorResource(id = io.homeassistant.companion.android.common.R.color.colorPrimary),
),
modifier = Modifier
.padding(bottom = 10.dp)
.align(alignment = Alignment.BottomCenter),
text = stringResource(id = io.homeassistant.companion.android.common.R.string.login),
)
}
Spacer(modifier = Modifier.height(5.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
visualTransformation = PasswordVisualTransformation()
)
if(isLoading){
Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
}else{
Button(
onClick = { onLoginClick(email, password) },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(id = io.homeassistant.companion.android.common.R.color.colorPrimary)
)
) {
Text(
stringResource(id = io.homeassistant.companion.android.common.R.string.login),
color = Color.White,
modifier = Modifier.padding(vertical = 10.dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
val context = LocalContext.current
Text(
text = "Need Help?",
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://mysmarthomes.us/"))
context.startActivity(intent)
},
color = colorResource(id = io.homeassistant.companion.android.common.R.color.colorPrimary),
)
}
}
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/login/HassioUserSession.kt
package io.homeassistant.companion.android.onboarding.login
object HassioUserSession {
var externalUrl: String? = null
var internalUrl: String? = null
var webviewUsername: String? = null
var webviewPassword: String? = null
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/login/CryptoUtil.kt
package io.homeassistant.companion.android.onboarding.login
import io.homeassistant.companion.android.BuildConfig
import java.util.Base64
import java.security.Key
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class CryptoUtil {
fun aes256CbcPkcs7Decrypt(encryptedBase64: String): String {
val encryptedData = Base64.getDecoder().decode(encryptedBase64)
val secretKeyString = BuildConfig.MSH_AES_KEY
val ivString = BuildConfig.MSH_AES_IV
if (secretKeyString.length != 32) throw IllegalArgumentException("AES key must be 32 characters long for AES-256.")
if (ivString.length != 16) throw IllegalArgumentException("AES IV must be 16 characters long for AES-CBC.")
val secretKey: Key = SecretKeySpec(secretKeyString.toByteArray(Charsets.UTF_8), "AES")
val ivSpec = IvParameterSpec(ivString.toByteArray(Charsets.UTF_8))
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
val decryptedBytes = cipher.doFinal(encryptedData)
return String(decryptedBytes, Charsets.UTF_8)
}
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/login/ServerListChooser.kt
package io.homeassistant.companion.android.onboarding.login
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import android.app.AlertDialog
import android.content.Context
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.ListView
import android.widget.TextView
import io.homeassistant.companion.android.R
class ServerListChooser {
data class ServerItem(val name: String, val doc: DocumentSnapshot)
companion object {
lateinit var dialog: AlertDialog
public suspend fun showServerSelectionDialog(
context: Context,
serverPasswords: List<DocumentSnapshot>
): DocumentSnapshot = suspendCoroutine { continuation ->
val builder = AlertDialog.Builder(context)
val inflater = LayoutInflater.from(context)
val dialogView = inflater.inflate(R.layout.msh_server_selection_dialog, null)
val listView = dialogView.findViewById<ListView>(R.id.server_list)
class ServerAdapter(
context: Context,
private val servers: List<ServerItem>
) : ArrayAdapter<ServerItem>(context, R.layout.msh_server_list_item, servers) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: inflater.inflate(R.layout.msh_server_list_item, parent, false)
val serverIcon = view.findViewById<ImageView>(R.id.server_icon)
val serverName = view.findViewById<TextView>(R.id.server_name)
val serverUrl = view.findViewById<TextView>(R.id.server_url)
val serverId = view.findViewById<TextView>(R.id.server_id)
val server = servers[position]
serverIcon.setImageResource(R.drawable.app_icon_round)
serverName.text = server.name
serverId.text = server.doc.id
return view
}
}
CoroutineScope(Dispatchers.Main).launch {
val serverItems = mutableListOf<ServerItem>()
serverPasswords.forEach { doc ->
val serverId = doc.id
try {
val serverDoc = FirebaseFirestore.getInstance()
.collection("servers")
.document(serverId)
.get()
.await()
val serverName = serverDoc.getString("homeName") ?: serverId
serverItems.add(ServerItem(serverName, doc))
} catch (e: Exception) {
serverItems.add(ServerItem(serverId, doc))
}
}
val adapter = ServerAdapter(context, serverItems)
listView.adapter = adapter
listView.setOnItemClickListener { _, _, position, _ ->
dialog.dismiss()
continuation.resume(serverItems[position].doc)
}
dialog = builder.setTitle("Select Server")
.setView(dialogView)
.setOnCancelListener {
continuation.resumeWithException(Exception("Server selection cancelled"))
}
.create()
dialog.show()
}
}
}
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/login/ServerTimeFetchService.kt
NOTE: In the original project this file has no package declaration (it's in the default package). You should add the proper package for your project.
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import android.util.Log
interface ServerTimeFetchService {
suspend fun fetchServerTime(): Long?
}
class ServerTimeFetchServiceImpl : ServerTimeFetchService {
private val client = OkHttpClient()
private val url = "https://getservertime-jrskleaqea-uc.a.run.app"
override suspend fun fetchServerTime(): Long? {
print("fetching server time.")
return try {
val request = Request.Builder().url(url).build()
val response: Response = client.newCall(request).execute()
if (response.isSuccessful) {
val jsonResponse = JSONObject(response.body?.string() ?: "")
val timeString = jsonResponse.getString("time")
val formatter = DateTimeFormatter.ISO_DATE_TIME
val serverTime = OffsetDateTime.parse(timeString, formatter).toInstant().toEpochMilli()
serverTime
} else {
Log.e("ServerTimeFetcher", "Failed with code: ${response.code}")
null
}
} catch (e: Exception) {
Log.e("ServerTimeFetcher", "Exception: ${e.message}")
null
}
}
}Path: app/src/main/java/io/homeassistant/companion/android/util/MSHAutoWifiManager.kt
package io.homeassistant.companion.android.util
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.data.wifi.WifiHelper
import io.homeassistant.companion.android.onboarding.login.HassioUserSession
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MSHAutoWifiManager @Inject constructor(
@ApplicationContext private val context: Context,
private val serverManager: ServerManager,
private val wifiHelper: WifiHelper
) {
private val scope = CoroutineScope(Dispatchers.IO)
fun autoAddCurrentWifi(serverId: Int) {
scope.launch {
try {
if (!wifiHelper.isUsingWifi()) {
Log.d(TAG, "Not using WiFi, skipping auto-add")
return@launch
}
val currentSsid = wifiHelper.getWifiSsid()?.removeSurrounding("\"")
if (currentSsid.isNullOrBlank()) {
Log.d(TAG, "No valid SSID found")
return@launch
}
val server = serverManager.getServer(serverId) ?: run {
Log.e(TAG, "Server not found for ID: $serverId")
return@launch
}
if (server.connection.internalSsids.contains(currentSsid)) {
Log.d(TAG, "SSID $currentSsid already in list")
return@launch
}
val updatedSsids = (server.connection.internalSsids + currentSsid).sorted()
serverManager.updateServer(
server.copy(
connection = server.connection.copy(
internalSsids = updatedSsids
)
)
)
Log.d(TAG, "Successfully added SSID: $currentSsid")
} catch (e: Exception) {
Log.e(TAG, "Error auto-adding WiFi SSID", e)
}
}
}
fun autoAddCurrentWifiToAllServers() {
val internalUrl = HassioUserSession.internalUrl ?: "";
scope.launch {
try {
val servers = serverManager.defaultServers
for (server in servers) {
autoAddCurrentWifi(server.id)
updateServerInternalUrl(server.id, internalUrl);
}
} catch (e: Exception) {
Log.e(TAG, "Error auto-adding WiFi SSID to all servers", e)
}
}
}
fun updateServerInternalUrl(serverId: Int, newInternalUrl: String) {
scope.launch {
try {
val server = serverManager.getServer(serverId) ?: run {
Log.e(TAG, "Server not found for ID: $serverId")
return@launch
}
val updatedServer = server.copy(
connection = server.connection.copy(
internalUrl = newInternalUrl
)
)
serverManager.updateServer(updatedServer)
Log.d(TAG, "Successfully updated internal URL to: $newInternalUrl")
} catch (e: Exception) {
Log.e(TAG, "Error updating internal URL", e)
}
}
}
companion object {
private const val TAG = "AutoWifiManager"
}
}Path: app/src/main/java/io/homeassistant/companion/android/util/VersionChecker.kt
package io.homeassistant.companion.android.util
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
class VersionChecker {
companion object {
val THIS_APP_VERSION_CODE = 1;
private val VERSION_URL = "https://my-smart-homes.github.io/app-landing/version.json?rand=${System.currentTimeMillis()}"
suspend fun checkForUpdate(context: Context, onUpdateAvailable: () -> Unit) {
try {
val url = URL(VERSION_URL)
withContext(Dispatchers.IO) {
(url.openConnection() as? HttpURLConnection)?.run {
requestMethod = "GET"
inputStream.bufferedReader().use {
val response = it.readText()
val jsonObject = JSONObject(response)
val latestVersionCode = jsonObject.getInt("latest_version_code")
if (latestVersionCode > THIS_APP_VERSION_CODE) {
withContext(Dispatchers.Main) { onUpdateAvailable() }
}
}
}
}
} catch (e: Exception) {
Log.e("VersionChecker", "Failed to check version", e)
}
}
}
}Path: app/src/main/java/io/homeassistant/companion/android/util/UpdateDialogUtil.kt
package io.homeassistant.companion.android.util
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AlertDialog
import android.net.Uri
object DialogUtils {
fun showUpdateDialog(context: Context) {
AlertDialog.Builder(context).apply {
setTitle("Update Available")
setMessage("A new version is available. Please update to continue.")
setPositiveButton("Update") { _, _ ->
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://my-smart-homes.github.io/app-landing/"))
context.startActivity(browserIntent)
}
setNegativeButton("Later", null)
show()
}
}
}Path: common/src/main/java/io/homeassistant/companion/android/common/util/LocationPermissionInfoHandler.kt
package io.homeassistant.companion.android.common.util
import android.Manifest
import android.content.Context
import androidx.appcompat.app.AlertDialog
import io.homeassistant.companion.android.common.R as commonR
object LocationPermissionInfoHandler {
fun showLocationPermInfoDialogIfNeeded(context: Context, permissions: Array<String>, continueYesCallback: () -> Unit, continueNoCallback: (() -> Unit)? = null) {
if (permissions.any {
it == Manifest.permission.ACCESS_FINE_LOCATION || it == Manifest.permission.ACCESS_BACKGROUND_LOCATION
}
) {
AlertDialog.Builder(context)
.setTitle(commonR.string.location_perm_info_title)
.setMessage(context.getString(commonR.string.location_perm_info_message))
.setPositiveButton(commonR.string.confirm_positive) { dialog, _ ->
dialog.dismiss()
continueYesCallback()
}
.setNegativeButton(commonR.string.confirm_negative) { dialog, _ ->
dialog.dismiss()
if (continueNoCallback != null) continueNoCallback()
}
.show()
} else {
continueYesCallback()
}
}
}Path: app/src/main/res/drawable/login_bg_shape.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="375dp"
android:height="311dp"
android:viewportWidth="375"
android:viewportHeight="311">
<path
android:pathData="M375,0H0V311L171.26,192.24C182.23,184.63 196.77,184.63 207.74,192.24L375,308.23V0Z"
android:fillColor="#3EBDF1"
android:fillType="evenOdd"/>
</vector>Path: app/src/main/res/drawable/my_smart_home_icon.xml
This is a vector drawable of the MSH logo (house + wifi signals + "MY SMART HOMES" text). Copy the full file from the source project.
Path: app/src/main/res/layout/msh_server_selection_dialog.xml
<?xml version="1.0" encoding="utf-8"?>
<ListView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/server_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp" />Path: app/src/main/res/layout/msh_server_list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/server_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="16dp"
android:scaleType="fitCenter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/server_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/server_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#666666" />
<TextView
android:id="@+id/server_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#666666" />
</LinearLayout>
</LinearLayout>Path: app/src/main/java/io/homeassistant/companion/android/onboarding/welcome/WelcomeFragment.kt
Changes:
- Add import:
import io.homeassistant.companion.android.onboarding.login.LoginFragment - Add import:
import io.homeassistant.companion.android.common.util.LocationPermissionInfoHandler - Add import:
import io.homeassistant.companion.android.util.DialogUtils - Add import:
import io.homeassistant.companion.android.util.VersionChecker - Replace the
welcomeNavigation()function body: Instead of navigating to DiscoveryFragment/ManualSetupFragment, it now checks location permissions and then callsnavigateToLogin() - Add
navigateToLogin()function that replaces content withLoginFragment - Add
checkForAppUpdate()function that callsVersionChecker.checkForUpdate()and showsDialogUtils.showUpdateDialog()on update available - The original navigation to DiscoveryFragment/ManualSetupFragment is commented out
Full replacement for welcomeNavigation():
private fun welcomeNavigation() {
val permissionsToCheck = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION
)
}
else -> {
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION)
}
}
if (!checkPermissions(permissionsToCheck)) {
LocationPermissionInfoHandler.showLocationPermInfoDialogIfNeeded(
requireContext(),
permissionsToCheck,
continueYesCallback = {
requestLocationPermission()
},
continueNoCallback = {
navigateToLogin()
}
)
} else {
navigateToLogin()
}
}
private fun navigateToLogin() {
parentFragmentManager
.beginTransaction()
.replace(R.id.content, LoginFragment::class.java, null)
.addToBackStack("Login")
.commit()
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt
Changes:
- Add import:
import io.homeassistant.companion.android.onboarding.login.HassioUserSession - Add import:
import org.json.JSONObject - In the
WebViewClient.onPageFinished()callback, add call:injectAutoLoginScript(view) - Add the entire
injectAutoLoginScript(webView: WebView?)function (see below)
The injection function to add:
private fun injectAutoLoginScript(webView: WebView?) {
val username = HassioUserSession.webviewUsername ?: ""
val password = HassioUserSession.webviewPassword ?: ""
val escapedUsername = JSONObject.quote(username)
val escapedPassword = JSONObject.quote(password)
val jsScript = """
(function() {
let found = false;
function createLoadingOverlay() {
var overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'white';
overlay.style.zIndex = '9999';
overlay.style.display = 'flex';
overlay.style.flexDirection = 'column';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.innerHTML = `
<div class="spinner"></div>
<p style="font-size: 24px; font-family: Arial, sans-serif; color: black;">Signing in...</p>
`;
var style = document.createElement('style');
style.innerHTML = `
.spinner {
border: 8px solid #f3f3f3;
border-top: 8px solid #3498db;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function showErrorInOverlay() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.innerHTML = `
<p style="font-size: 24px; font-family: Arial, sans-serif; color: red;text-align:center">⚠️<br/><br/>Invalid username or password.</p>
`;
}
}
function removeLoadingOverlay() {
var overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.remove();
}
}
function checkForErrorAlert() {
const interval = setInterval(() => {
const isErrorAlertPresent = document.querySelector('ha-alert[alert-type="error"]') !== null;
if (isErrorAlertPresent) {
clearInterval(interval);
showErrorInOverlay();
}
}, 1000);
}
function checkInputElement() {
if (found) { return; }
var inputElement = document.querySelector('input[name="username"]');
if (inputElement) {
found = true;
console.log("Input element found");
doSignIn();
} else {
console.log("Input element not found");
}
}
function doSignIn() {
var usernameInput = document.querySelector('input[name="username"]');
var passwordInput = document.querySelector('input[name="password"]');
var loginButton = document.querySelector('mwc-button');
usernameInput.value = ${'$'}escapedUsername;
var usernameEvent = new Event('input', { bubbles: true });
usernameInput.dispatchEvent(usernameEvent);
passwordInput.value = ${'$'}escapedPassword;
var passwordEvent = new Event('input', { bubbles: true });
passwordInput.dispatchEvent(passwordEvent);
setTimeout(function() {
var clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
});
loginButton.dispatchEvent(clickEvent);
checkForErrorAlert();
}, 100);
}
setInterval(checkInputElement, 1000);
createLoadingOverlay();
})();
""".trimIndent()
Log.d(TAG, "Executing JavaScript: ${'$'}jsScript")
webView?.evaluateJavascript(jsScript) { result ->
Log.d(TAG, "JavaScript execution result: ${'$'}result")
}
}IMPORTANT: In the actual Kotlin file, the
$escapedUsernameand$escapedPasswordinside the JS string template use Kotlin string interpolation directly (no${'$'}). The escaping above is just for markdown rendering. Use$escapedUsernameand$escapedPassworddirectly.
Path: app/src/main/java/io/homeassistant/companion/android/onboarding/manual/ManualSetupView.kt
Changes:
- Add import:
import io.homeassistant.companion.android.onboarding.login.HassioUserSession - Add import:
import androidx.compose.runtime.LaunchedEffect - Add import:
import androidx.compose.runtime.saveable.rememberSaveable - Add import:
import kotlinx.coroutines.launch - Add a
LaunchedEffect(Unit)block at the top of the composable that:- Reads
HassioUserSession.externalUrl - If not empty, sets
manualUrl.value = externalUrl - Calls
connectedClicked()automatically - Shows a loading overlay while connecting
- Reads
val isLoading = rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
val externalUrl = HassioUserSession.externalUrl ?: ""
if (externalUrl.isNotEmpty() && isLoading.value == true) {
manualUrl.value = externalUrl
connectedClicked()
launch {
kotlinx.coroutines.delay(1000)
isLoading.value = false
}
}
}And at the bottom of the composable, add a loading overlay:
if (isLoading.value) {
Surface(
color = MaterialTheme.colors.background.copy(alpha = 1f),
modifier = Modifier.fillMaxSize()
) { }
}Path: app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationFragment.kt
Changes:
- Add import:
import io.homeassistant.companion.android.onboarding.login.HassioUserSession - Add import:
import io.homeassistant.companion.android.util.MSHAutoWifiManager - Add field:
@Inject lateinit var autoWifiManager: MSHAutoWifiManager
Path: app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt
Changes:
- Add import:
import io.homeassistant.companion.android.onboarding.login.HassioUserSession - Add import:
import io.homeassistant.companion.android.util.MSHAutoWifiManager - Add field:
@Inject lateinit var autoWifiManager: MSHAutoWifiManager - After WebView setup is complete, add:
Log.d(TAG, "HassioUserSession.internalUrl ${HassioUserSession.internalUrl}");
if(HassioUserSession.internalUrl!=null){
autoWifiManager.autoAddCurrentWifiToAllServers()
}Add BuildConfig fields (inside defaultConfig or buildTypes):
buildConfigField("String", "MSH_AES_KEY", "\"${System.getenv("MSH_AES_KEY") ?: ""}\"")
buildConfigField("String", "MSH_AES_IV", "\"${System.getenv("MSH_AES_IV") ?: ""}\"")Add dependencies:
implementation(libs.firebase.auth.ktx)
implementation(libs.firebase.firestore.ktx)Also ensure Firebase BOM and messaging are in the full variant:
"fullImplementation"(platform(libs.firebase.bom))
"fullImplementation"(libs.firebase.messaging)In common/src/main/res/values/strings.xml, ensure these strings exist:
login→ "Log in"location_perm_info_title→ "Location access"location_perm_info_message→ (Location permission explanation text)confirm_positive→ (e.g., "Allow" or "Yes")confirm_negative→ (e.g., "Deny" or "No")
Set these env vars for the AES decryption to work:
MSH_AES_KEY— 32-character AES-256 keyMSH_AES_IV— 16-character AES CBC IV
users/{userId}
├── email: String
├── subscription.expiresAt: Timestamp
└── serverPasswords/ (subcollection)
└── {serverId}
└── encryptedPass: String
servers/{serverId}
├── externalUrl: String
├── internalUrl: String
└── homeName: String
| # | Action | File Path |
|---|---|---|
| 1 | CREATE | app/.../onboarding/login/LoginFragment.kt |
| 2 | CREATE | app/.../onboarding/login/LoginView.kt |
| 3 | CREATE | app/.../onboarding/login/HassioUserSession.kt |
| 4 | CREATE | app/.../onboarding/login/CryptoUtil.kt |
| 5 | CREATE | app/.../onboarding/login/ServerListChooser.kt |
| 6 | CREATE | app/.../onboarding/login/ServerTimeFetchService.kt |
| 7 | CREATE | app/.../util/MSHAutoWifiManager.kt |
| 8 | CREATE | app/.../util/VersionChecker.kt |
| 9 | CREATE | app/.../util/UpdateDialogUtil.kt |
| 10 | CREATE | common/.../util/LocationPermissionInfoHandler.kt |
| 11 | CREATE | app/src/main/res/drawable/login_bg_shape.xml |
| 12 | CREATE | app/src/main/res/drawable/my_smart_home_icon.xml |
| 13 | CREATE | app/src/main/res/layout/msh_server_selection_dialog.xml |
| 14 | CREATE | app/src/main/res/layout/msh_server_list_item.xml |
| 15 | MODIFY | app/.../onboarding/welcome/WelcomeFragment.kt |
| 16 | MODIFY | app/.../onboarding/authentication/AuthenticationFragment.kt |
| 17 | MODIFY | app/.../onboarding/manual/ManualSetupView.kt |
| 18 | MODIFY | app/.../onboarding/integration/MobileAppIntegrationFragment.kt |
| 19 | MODIFY | app/.../webview/WebViewActivity.kt |
| 20 | MODIFY | app/build.gradle.kts |