Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1caf2a2
Add reverse proxy web authentication support
Meganitrospeed Aug 23, 2026
da0f7ab
Add reverse proxy web authentication support
Meganitrospeed Aug 23, 2026
376ab45
Wire reverse proxy cookies into network stack
Meganitrospeed Aug 23, 2026
74f1c62
Wire reverse proxy cookies into network stack
Meganitrospeed Aug 23, 2026
868d949
Wire reverse proxy cookies into network stack
Meganitrospeed Aug 23, 2026
8a46d8d
Detect interactive reverse proxy authentication
Meganitrospeed Aug 23, 2026
d4c4d71
Launch reverse proxy login during onboarding
Meganitrospeed Aug 23, 2026
2908f2c
Handle expired reverse proxy sessions
Meganitrospeed Aug 23, 2026
7dcd027
Detect expired proxy sessions in API responses
Meganitrospeed Aug 23, 2026
39f98a3
Detect expired proxy sessions in API responses
Meganitrospeed Aug 23, 2026
ba06f8f
Detect expired proxy sessions in API responses
Meganitrospeed Aug 23, 2026
ef3ea06
Reauthenticate when proxy sessions expire
Meganitrospeed Aug 23, 2026
09b8e48
Add reverse proxy authentication tests
Meganitrospeed Aug 23, 2026
aac3ffa
Add reverse proxy authentication tests
Meganitrospeed Aug 23, 2026
71fe9fe
Use tested reverse proxy challenge detector
Meganitrospeed Aug 23, 2026
f6434c5
Use tested reverse proxy challenge detector
Meganitrospeed Aug 23, 2026
a4d5630
Use tested reverse proxy challenge detector
Meganitrospeed Aug 23, 2026
531840d
Use tested reverse proxy challenge detector
Meganitrospeed Aug 23, 2026
d9ef1fb
Use tested reverse proxy challenge detector
Meganitrospeed Aug 23, 2026
fe7768c
Run tests for reverse proxy authentication
Meganitrospeed Aug 23, 2026
85e7cf7
Run authentication tests on pull requests
Meganitrospeed Aug 23, 2026
ae3b90d
Publish reverse proxy test APK
Meganitrospeed Aug 23, 2026
7ff1df0
Remove temporary reverse proxy CI workflow
Meganitrospeed Aug 23, 2026
3b88a99
Fix reverse proxy reauthentication lifecycle
Meganitrospeed Aug 23, 2026
bc07815
Temporarily verify reverse proxy fixes
Meganitrospeed Aug 23, 2026
5f8f950
Remove temporary reverse proxy CI workflow
Meganitrospeed Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ dependencies {
implementation(libs.zxing.android.embedded)
// For image rotation
implementation(libs.exifinterface)
testImplementation(libs.junit)
testImplementation(libs.arch.core.testing)
// https://github.com/journeyapps/zxing-android-embedded#option-2-desugaring-advanced
// prevents bug https://github.com/patzly/grocy-android/issues/425
coreLibraryDesugaring(libs.desugar)
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@

</activity>

<activity
android:name=".activity.ReverseProxyAuthActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />

<activity
android:name=".activity.MainActivity"
android:windowSoftInputMode="adjustNothing"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

package xyz.zedler.patrick.grocy.activity;

import android.app.Activity;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
Expand Down Expand Up @@ -48,6 +49,8 @@
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.DrawableRes;
import androidx.annotation.MenuRes;
import androidx.annotation.NonNull;
Expand Down Expand Up @@ -96,6 +99,7 @@
import xyz.zedler.patrick.grocy.util.VersionUtil;
import xyz.zedler.patrick.grocy.util.ViewUtil;
import xyz.zedler.patrick.grocy.web.OrbotHelper;
import xyz.zedler.patrick.grocy.web.ReverseProxyAuthManager;

public class MainActivity extends AppCompatActivity {

Expand All @@ -114,6 +118,17 @@ public class MainActivity extends AppCompatActivity {
private UiUtil uiUtil;
private boolean runAsSuperClass;
private boolean debug;
private boolean reverseProxyAuthOpen;
private final ActivityResultLauncher<Intent> reverseProxyAuthLauncher =
registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
reverseProxyAuthOpen = false;
if (result.getResultCode() == Activity.RESULT_OK) {
recreate();
}
}
);

@Override
protected void onCreate(Bundle savedInstanceState) {
Expand All @@ -127,6 +142,7 @@ protected void onCreate(Bundle savedInstanceState) {

sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this);
PrefsUtil.migratePrefs(sharedPrefs);
ReverseProxyAuthManager.configure(sharedPrefs.getString(Constants.PREF.SERVER_URL, null));
debug = PrefsUtil.isDebuggingEnabled(sharedPrefs);

// DARK MODE AND THEME
Expand Down Expand Up @@ -203,6 +219,16 @@ public void onReceive(Context context, Intent intent) {
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

ReverseProxyAuthManager.getAuthenticationRequired().observe(this, url -> {
if (!ReverseProxyAuthManager.shouldLaunchAuthentication(url, reverseProxyAuthOpen)) {
return;
}
reverseProxyAuthOpen = true;
Intent intent = new Intent(this, ReverseProxyAuthActivity.class);
intent.putExtra(ReverseProxyAuthActivity.EXTRA_TARGET_URL, url);
reverseProxyAuthLauncher.launch(intent);
});

// NAVIGATION
fragmentManager = getSupportFragmentManager();
navUtil = new NavUtil(this, (controller, dest, args) -> {
Expand Down Expand Up @@ -645,4 +671,4 @@ public void setHapticEnabled(boolean enabled) {
public void saveInstanceState(Bundle outState) {
onSaveInstanceState(outState);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* This file is part of Grocy Android.
*
* Grocy Android is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Copyright (c) 2020-2024 by Patrick Zedler and Dominic Zedler
* Copyright (c) 2024-2026 by Patrick Zedler
*/

package xyz.zedler.patrick.grocy.activity;

import android.app.Activity;
import android.content.Intent;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import xyz.zedler.patrick.grocy.web.ReverseProxyAuthDetector;

/**
* Hosts an interactive reverse-proxy login and returns after the provider redirects back to Grocy.
*/
public class ReverseProxyAuthActivity extends AppCompatActivity {

public static final String EXTRA_TARGET_URL = "target_url";

private WebView webView;
private Uri targetUri;

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

String targetUrl = getIntent().getStringExtra(EXTRA_TARGET_URL);
targetUri = targetUrl == null ? null : Uri.parse(targetUrl);
if (targetUri == null || targetUri.getHost() == null || !isHttp(targetUri)) {
setResult(Activity.RESULT_CANCELED);
finish();
return;
}

FrameLayout container = new FrameLayout(this);
webView = new WebView(this);
ProgressBar progress = new ProgressBar(this);

container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
FrameLayout.LayoutParams progressParams = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
progressParams.gravity = android.view.Gravity.CENTER;
container.addView(progress, progressParams);
setContentView(container);

CookieManager cookies = CookieManager.getInstance();
cookies.setAcceptCookie(true);
cookies.setAcceptThirdPartyCookies(webView, true);

WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
settings.setSafeBrowsingEnabled(true);
}

webView.setBackgroundColor(Color.TRANSPARENT);
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
return false;
}

@Override
public void onPageFinished(WebView view, String url) {
progress.setVisibility(android.view.View.GONE);
Uri current = Uri.parse(url);
if (ReverseProxyAuthDetector.isSameOrigin(targetUri.toString(), current.toString())
&& !ReverseProxyAuthDetector.isAuthenticationPath(current.getPath())) {
CookieManager.getInstance().flush();
setResult(Activity.RESULT_OK);
finish();
}
}
});
webView.loadUrl(targetUri.toString());
}

@Override
protected void onDestroy() {
if (webView != null) {
webView.stopLoading();
webView.loadUrl("about:blank");
webView.clearHistory();
webView.removeAllViews();
webView.destroy();
webView = null;
}
super.onDestroy();
}

private static boolean isHttp(Uri uri) {
return "https".equalsIgnoreCase(uri.getScheme())
|| "http".equalsIgnoreCase(uri.getScheme());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,22 @@

package xyz.zedler.patrick.grocy.fragment;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavOptions;
import xyz.zedler.patrick.grocy.R;
import xyz.zedler.patrick.grocy.activity.MainActivity;
import xyz.zedler.patrick.grocy.activity.ReverseProxyAuthActivity;
import xyz.zedler.patrick.grocy.behavior.SystemBarBehavior;
import xyz.zedler.patrick.grocy.databinding.FragmentLoginRequestBinding;
import xyz.zedler.patrick.grocy.model.BottomSheetEvent;
Expand All @@ -46,6 +51,20 @@ public class LoginRequestFragment extends BaseFragment {
private FragmentLoginRequestBinding binding;
private MainActivity activity;
private LoginRequestViewModel viewModel;
private final ActivityResultLauncher<Intent> reverseProxyAuthLauncher =
registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (viewModel == null) {
return;
}
if (result.getResultCode() == Activity.RESULT_OK) {
viewModel.onReverseProxyAuthenticated();
} else {
viewModel.consumeReverseProxyAuthRequest();
}
}
);

@Override
public View onCreateView(
Expand Down Expand Up @@ -76,6 +95,16 @@ public void onViewCreated(@Nullable View view, @Nullable Bundle savedInstanceSta
binding.setClickUtil(new ClickUtil());
binding.setLifecycleOwner(getViewLifecycleOwner());

viewModel.getReverseProxyAuthRequired().observe(getViewLifecycleOwner(), required -> {
if (!Boolean.TRUE.equals(required)) {
return;
}
viewModel.consumeReverseProxyAuthRequest();
Intent intent = new Intent(activity, ReverseProxyAuthActivity.class);
intent.putExtra(ReverseProxyAuthActivity.EXTRA_TARGET_URL, viewModel.getServerUrl());
reverseProxyAuthLauncher.launch(intent);
});

viewModel.getEventHandler().observeEvent(getViewLifecycleOwner(), event -> {
if (event.getType() == Event.SNACKBAR_MESSAGE) {
activity.showSnackbar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
import xyz.zedler.patrick.grocy.util.ConfigUtil;
import xyz.zedler.patrick.grocy.util.PrefsUtil;
import xyz.zedler.patrick.grocy.web.NetworkQueue.QueueItem;
import xyz.zedler.patrick.grocy.web.ReverseProxyAuthDetector;
import xyz.zedler.patrick.grocy.web.ReverseProxyAuthManager;

public class LoginRequestViewModel extends BaseViewModel {

Expand All @@ -72,6 +74,7 @@ public class LoginRequestViewModel extends BaseViewModel {
private final MutableLiveData<String> loginErrorExactMsg;
private final MutableLiveData<String> loginErrorHassMsg;
private final MutableLiveData<String> loginErrorHassLog;
private final MutableLiveData<Boolean> reverseProxyAuthRequired;

private final String serverUrl;
private final String homeAssistantServerUrl;
Expand Down Expand Up @@ -111,6 +114,7 @@ public LoginRequestViewModel(@NonNull Application application, LoginRequestFragm
loginErrorExactMsg = new MutableLiveData<>();
loginErrorHassMsg = new MutableLiveData<>();
loginErrorHassLog = new MutableLiveData<>();
reverseProxyAuthRequired = new MutableLiveData<>(false);
}

public void login() {
Expand All @@ -130,8 +134,12 @@ public void login() {
getSystemInfo(dlHelper, response -> {
if (!response.contains("grocy_version")) {
appendHassLog(" Error.\n");
loginErrorOccurred.setValue(true);
loginErrorMsg.setValue(getString(R.string.error_not_grocy_instance));
if (looksLikeInteractiveLogin(response)) {
reverseProxyAuthRequired.setValue(true);
} else {
loginErrorOccurred.setValue(true);
loginErrorMsg.setValue(getString(R.string.error_not_grocy_instance));
}
return;
}
try {
Expand All @@ -154,6 +162,7 @@ public void login() {
.putString(Constants.PREF.SERVER_URL, serverUrl)
.putString(Constants.PREF.API_KEY, apiKey)
.apply();
ReverseProxyAuthManager.configure(serverUrl);
if (useHassLoginFlow) {
sharedPrefs.edit().putString(
Constants.PREF.HOME_ASSISTANT_SERVER_URL,
Expand Down Expand Up @@ -186,6 +195,10 @@ public void login() {
},
error -> {
Log.e(TAG, "requestLogin: VolleyError: " + error);
if (looksLikeInteractiveLogin(error)) {
reverseProxyAuthRequired.setValue(true);
return;
}
loginErrorOccurred.setValue(true);
if (error instanceof AuthFailureError) {
loginErrorExactMsg.setValue(error.toString());
Expand Down Expand Up @@ -378,6 +391,48 @@ public MutableLiveData<String> getLoginErrorHassLog() {
return loginErrorHassLog;
}

public MutableLiveData<Boolean> getReverseProxyAuthRequired() {
return reverseProxyAuthRequired;
}

public String getServerUrl() {
return serverUrl;
}

public void consumeReverseProxyAuthRequest() {
reverseProxyAuthRequired.setValue(false);
}

public void onReverseProxyAuthenticated() {
reverseProxyAuthRequired.setValue(false);
login();
}

private static boolean looksLikeInteractiveLogin(@Nullable String response) {
if (response == null) {
return false;
}
return ReverseProxyAuthDetector.looksLikeLoginPage(response);
}

private static boolean looksLikeInteractiveLogin(com.android.volley.VolleyError error) {
if (error == null || error.networkResponse == null) {
return false;
}
String location = error.networkResponse.headers == null
? null
: error.networkResponse.headers.get("Location");
if (location != null && looksLikeInteractiveLogin(location)) {
return true;
}
if (error.networkResponse.data == null) {
return false;
}
return looksLikeInteractiveLogin(
new String(error.networkResponse.data, java.nio.charset.StandardCharsets.UTF_8)
);
}

public boolean isUseHassLoginFlow() {
return useHassLoginFlow;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ protected Response<JSONArray> parseNetworkResponse(NetworkResponse response) {
response.data,
HttpHeaderParser.parseCharset(response.headers, PROTOCOL_CHARSET)
);
if (ReverseProxyAuthManager.handleResponse(url, jsonString)) {
return Response.error(new com.android.volley.AuthFailureError());
}
JSONArray result = null;
if (jsonString.length() > 0) {
result = new JSONArray(jsonString);
Expand Down
Loading