diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 79849980..8220c70d 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -1,6 +1,17 @@ + + + + + + + + + + + @@ -12,6 +23,6 @@ - + \ No newline at end of file diff --git a/app/src/main/assets/waiting.html b/app/src/main/assets/waiting.html new file mode 100644 index 00000000..71f8c91c --- /dev/null +++ b/app/src/main/assets/waiting.html @@ -0,0 +1,15 @@ + + + + + Memories + + + +
+ +

+ Waiting for login to complete +

+ + \ No newline at end of file diff --git a/app/src/main/java/gallery/memories/MainActivity.kt b/app/src/main/java/gallery/memories/MainActivity.kt index ec6ca63f..cf21b253 100644 --- a/app/src/main/java/gallery/memories/MainActivity.kt +++ b/app/src/main/java/gallery/memories/MainActivity.kt @@ -25,6 +25,11 @@ import gallery.memories.databinding.ActivityMainBinding ActivityMainBinding.inflate(layoutInflater) } + companion object { + // replicate chrome: https://www.whatismybrowser.com/guides/the-latest-user-agent/chrome + val USER_AGENT = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.76 Mobile Safari/537.36 Memories/0.0" + } + private lateinit var mNativeX: NativeX private var player: ExoPlayer? = null @@ -135,12 +140,23 @@ import gallery.memories.databinding.ActivityMainBinding webSettings.allowContentAccess = true webSettings.domStorageEnabled = true webSettings.databaseEnabled = true - webSettings.userAgentString = "memories-native-android/0.0" + webSettings.userAgentString = USER_AGENT binding.webview.clearCache(true) binding.webview.addJavascriptInterface(mNativeX, "nativex") - binding.webview.loadUrl("file:///android_asset/welcome.html"); binding.webview.setBackgroundColor(Color.TRANSPARENT) WebView.setWebContentsDebuggingEnabled(true); + + // Load accounts + mNativeX.mAccountService.refreshAuthHeader() + val authHeader = mNativeX.mAccountService.authHeader + val memoriesUrl = mNativeX.mAccountService.memoriesUrl + if (authHeader != null && memoriesUrl != null) { + binding.webview.loadUrl(memoriesUrl, mapOf( + "Authorization" to authHeader + )) + } else { + binding.webview.loadUrl("file:///android_asset/welcome.html"); + } } fun initializePlayer(uri: Uri, uid: String) { diff --git a/app/src/main/java/gallery/memories/NativeX.kt b/app/src/main/java/gallery/memories/NativeX.kt index 7ee6e228..e64e76cc 100644 --- a/app/src/main/java/gallery/memories/NativeX.kt +++ b/app/src/main/java/gallery/memories/NativeX.kt @@ -1,6 +1,5 @@ package gallery.memories -import android.content.Intent import android.net.Uri import android.util.Log import android.view.SoundEffectConstants @@ -10,23 +9,20 @@ import android.webkit.WebResourceResponse import android.widget.Toast import androidx.media3.common.util.UnstableApi import gallery.memories.mapper.SystemImage +import gallery.memories.service.AccountService import gallery.memories.service.DownloadService import gallery.memories.service.ImageService import gallery.memories.service.TimelineQuery -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONObject import java.io.ByteArrayInputStream import java.net.URLDecoder @UnstableApi class NativeX(private val mActivity: MainActivity) { val TAG = "NativeX" - private val mImageService: ImageService = ImageService(mActivity) - private val mQuery: TimelineQuery = TimelineQuery(mActivity) private var themeStored = false + private val mImageService = ImageService(mActivity) + private val mQuery = TimelineQuery(mActivity) + val mAccountService = AccountService(mActivity) object API { val DAYS = Regex("^/api/days$") @@ -105,34 +101,7 @@ import java.net.URLDecoder @JavascriptInterface fun login(baseUrl: String?, loginFlowUrl: String?) { if (baseUrl == null || loginFlowUrl == null) return; - - // Make POST request to login flow URL - val client = OkHttpClient() - val request = Request.Builder() - .url(loginFlowUrl) - .post("".toRequestBody("application/json".toMediaTypeOrNull())) - .build() - val response = client.newCall(request).execute() - - // Read response body - val body = response.body?.string() - if (body == null) { - toast("Failed to get login flow response") - return - } - - // Parse response body as JSON - val json = JSONObject(body) - try { - val loginUrl = json.getString("login") - toast("Opening login page...") - - // Open login page in browser - mActivity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl))) - } catch (e: Exception) { - Log.e(TAG, "login: ", e) - toast("Failed to parse login flow response") - } + mAccountService.login(baseUrl, loginFlowUrl) } @JavascriptInterface diff --git a/app/src/main/java/gallery/memories/service/AccountService.kt b/app/src/main/java/gallery/memories/service/AccountService.kt new file mode 100644 index 00000000..f79fc867 --- /dev/null +++ b/app/src/main/java/gallery/memories/service/AccountService.kt @@ -0,0 +1,155 @@ +package gallery.memories.service + +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import gallery.memories.MainActivity +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +class AccountService(private val mActivity: MainActivity) { + companion object { + val TAG = "AccountService" + } + + var authHeader: String? = null + var memoriesUrl: String? = null + + private fun toast(message: String) { + mActivity.runOnUiThread { + Toast.makeText(mActivity, message, Toast.LENGTH_LONG).show() + } + } + + fun login(baseUrl: String, loginFlowUrl: String) { + // Make POST request to login flow URL + val client = OkHttpClient() + val request = Request.Builder() + .url(loginFlowUrl) + .header("User-Agent", "Memories") + .post("".toRequestBody("application/json".toMediaTypeOrNull())) + .build() + val response = client.newCall(request).execute() + + // Read response body + val body = response.body?.string() + response.body?.close() + if (body == null) { + toast("Failed to get login flow response") + return + } + + // Parse response body as JSON + val json = JSONObject(body) + val pollToken: String + val pollUrl: String + val loginUrl: String + try { + val pollObj = json.getJSONObject("poll") + pollToken = pollObj.getString("token") + pollUrl = pollObj.getString("endpoint") + loginUrl = json.getString("login") + + toast("Opening login page...") + + // Open login page in browser + mActivity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl))) + } catch (e: Exception) { + Log.e(TAG, "login: ", e) + toast("Failed to parse login flow response") + return + } + + // Start polling in background + Thread { + pollLogin(pollUrl, pollToken, baseUrl) + }.start() + } + + private fun pollLogin(pollUrl: String, pollToken: String, baseUrl: String) { + mActivity.binding.webview.post { + mActivity.binding.webview.loadUrl("file:///android_asset/waiting.html") + } + + val client = OkHttpClient() + val rbody = "token=$pollToken".toRequestBody("application/x-www-form-urlencoded".toMediaTypeOrNull()) + var pollCount = 0 + + while (true) { + pollCount += 3 + if (pollCount >= 10 * 60) return + + // Sleep for 3s + Thread.sleep(3000) + + // Poll login flow URL + val request = Request.Builder() + .url(pollUrl) + .post(rbody) + .build() + val response = client.newCall(request).execute() + Log.v(TAG, "pollLogin: Got status code ${response.code}") + + // Check status code + if (response.code != 200) { + response.body?.close() + continue + } + + // Read response body + val body = response.body!!.string() + response.body?.close() + val json = JSONObject(body) + val loginName = json.getString("loginName") + val appPassword = json.getString("appPassword") + + mActivity.runOnUiThread { + // Save login info (also updates header) + storeCredentials(baseUrl, loginName, appPassword) + + // Load main view + mActivity.binding.webview.loadUrl(baseUrl, mapOf( + "Authorization" to authHeader + )) + } + + return; + } + } + + fun storeCredentials(url: String, user: String, password: String) { + mActivity.getSharedPreferences("credentials", 0).edit() + .putString("memoriesUrl", url) + .putString("user", user) + .putString("password", password) + .apply() + setAuthHeader(Pair(user, password)) + } + + fun getCredentials(): Pair? { + val prefs = mActivity.getSharedPreferences("credentials", 0) + memoriesUrl = prefs.getString("memoriesUrl", null) + val user = prefs.getString("user", null) + val password = prefs.getString("password", null) + if (user == null || password == null) return null + return Pair(user, password) + } + + fun refreshAuthHeader() { + setAuthHeader(getCredentials()) + } + + private fun setAuthHeader(credentials: Pair?) { + if (credentials != null) { + val auth = "${credentials.first}:${credentials.second}" + authHeader = "Basic ${Base64.encodeToString(auth.toByteArray(), Base64.NO_WRAP)}" + return + } + authHeader = null + } +} \ No newline at end of file