Merge pull request #38 from bmlzootown/login-fix

Login fix
This commit is contained in:
Brandon
2025-12-04 01:50:07 -05:00
committed by GitHub
24 changed files with 1370 additions and 545 deletions

7
.gitignore vendored
View File

@@ -53,6 +53,10 @@ captures/
.idea/navEditor.xml
.idea/deploymentTargetDropDown.xml
# VSCode
.vscode/
app/release/
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
@@ -88,4 +92,5 @@ lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
*.hprof
floatplane-openapi-specification-trimmed.json

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
<bytecodeTargetLevel target="21" />
</component>
</project>

3
.idea/misc.xml generated
View File

@@ -18,7 +18,8 @@
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@@ -2,16 +2,16 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 33
compileSdkVersion 34
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "ml.bmlzootown.hydravion"
minSdkVersion 26
targetSdkVersion 33
versionCode 2
versionName "1.5.2"
targetSdkVersion 34
versionCode 6
versionName "1.5.6"
}
@@ -78,4 +78,11 @@ dependencies {
// Version Compare
implementation("io.github.g00fy2:versioncompare:1.5.0")
// Local HTTP Server
implementation 'org.nanohttpd:nanohttpd:2.3.1'
// QR Code Generation
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
}

View File

@@ -26,6 +26,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -58,10 +59,9 @@
<activity
android:name=".playback.PlaybackActivity"
android:exported="false" />
<!-- Not sure if this one needs to be exported or not -->
<activity
android:name=".authenticate.LoginActivity"
android:exported="true"
android:name=".authenticate.QrLoginActivity"
android:exported="false"
android:noHistory="true" />
</application>

View File

@@ -2,7 +2,10 @@ package ml.bmlzootown.hydravion
object Constants {
const val PREF_SAIL_SSID = "sails.sid"
// OAuth2 / OpenID Connect token preferences
const val PREF_ACCESS_TOKEN = "access_token"
const val PREF_REFRESH_TOKEN = "refresh_token"
const val PREF_TOKEN_EXPIRES_AT = "token_expires_at"
const val REQ_CODE_DETAIL = 1
}

View File

@@ -0,0 +1,238 @@
package ml.bmlzootown.hydravion.authenticate
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.android.volley.Request
import com.android.volley.RequestQueue
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import ml.bmlzootown.hydravion.Constants
import org.json.JSONObject
import java.util.concurrent.ConcurrentLinkedQueue
// Centralized helper for managing OAuth tokens and automatic access-token refresh.
class AuthManager private constructor(
private val context: Context,
private val prefs: SharedPreferences
) {
private val queue: RequestQueue = Volley.newRequestQueue(context.applicationContext)
private val TAG = "AuthManager"
// Cache for validated token to avoid re-validation on every request
@Volatile
private var cachedToken: String? = null
@Volatile
private var cacheValidUntil: Long = 0
// Queue for concurrent token validation requests
private val pendingCallbacks = ConcurrentLinkedQueue<Pair<(String) -> Unit, (() -> Unit)?>>()
@Volatile
private var isRefreshing = false
private val tokenEndpoint =
"https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token"
private val clientId = "hydravion"
fun getAccessToken(): String =
prefs.getString(Constants.PREF_ACCESS_TOKEN, "") ?: ""
fun getRefreshToken(): String =
prefs.getString(Constants.PREF_REFRESH_TOKEN, "") ?: ""
private fun getExpiresAt(): Long =
prefs.getLong(Constants.PREF_TOKEN_EXPIRES_AT, 0L)
fun clearTokens() {
prefs.edit()
.remove(Constants.PREF_ACCESS_TOKEN)
.remove(Constants.PREF_REFRESH_TOKEN)
.remove(Constants.PREF_TOKEN_EXPIRES_AT)
.commit()
// Clear cache when tokens are cleared
cachedToken = null
cacheValidUntil = 0
}
private fun isAccessTokenValid(): Boolean {
val token = getAccessToken()
if (token.isEmpty()) {
Log.d(TAG, "Access token is empty")
return false
}
// Add 60s of leeway to avoid race with server-side expiry.
val now = System.currentTimeMillis()
val expiresAt = getExpiresAt()
val isValid = now + 60_000L < expiresAt
if (!isValid) {
val timeUntilExpiry = expiresAt - now
Log.d(TAG, "Access token expired or expiring soon. Time until expiry: ${timeUntilExpiry / 1000}s")
}
return isValid
}
// Ensures a valid access token, refreshing with the refresh token when necessary.
// - On success: invokes [onToken] with a non-empty access token.
// - On failure: clears stored tokens and invokes [onFailure].
// - Batches concurrent requests to avoid multiple simultaneous refresh attempts.
fun withValidAccessToken(
onToken: (String) -> Unit,
onFailure: (() -> Unit)? = null
) {
// Check cache first (valid for 30 seconds to reduce validation overhead)
val now = System.currentTimeMillis()
if (cachedToken != null && now < cacheValidUntil) {
Log.d(TAG, "Using cached valid token")
onToken(cachedToken!!)
return
}
// Check if token is valid in storage
if (isAccessTokenValid()) {
val token = getAccessToken()
// Cache the token for 30 seconds
cachedToken = token
cacheValidUntil = now + 30_000L
Log.d(TAG, "Access token is valid, using existing token (cached for 30s)")
onToken(token)
return
}
// If already refreshing, queue this callback to be notified when refresh completes
if (isRefreshing) {
Log.d(TAG, "Token refresh in progress, queuing callback")
pendingCallbacks.add(Pair(onToken, onFailure))
return
}
// Start refresh process
isRefreshing = true
Log.d(TAG, "Access token expired or invalid, attempting refresh")
val refreshToken = getRefreshToken()
if (refreshToken.isEmpty()) {
Log.w(TAG, "Refresh token is empty, cannot refresh. Clearing tokens.")
clearTokens()
// Notify all pending callbacks before returning
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
while (pendingCallbacks.isNotEmpty()) {
pendingCallbacks.poll()?.let { callbacks.add(it) }
}
isRefreshing = false
// Notify current caller
onFailure?.invoke()
// Notify all queued callbacks
callbacks.forEach { (_, failureCallback) ->
failureCallback?.invoke()
}
return
}
Log.d(TAG, "Refreshing access token using refresh token")
val request = object : StringRequest(
Method.POST,
tokenEndpoint,
{ response ->
try {
val json = JSONObject(response)
if (json.has("access_token")) {
val newAccessToken = json.getString("access_token")
val newRefreshToken = json.optString("refresh_token", refreshToken)
val expiresIn = json.optLong("expires_in", 1800L)
val expiresAt = System.currentTimeMillis() + expiresIn * 1000L
Log.d(TAG, "Token refresh successful. New token expires in ${expiresIn}s")
if (newRefreshToken != refreshToken) {
Log.d(TAG, "Received new refresh token")
}
prefs.edit()
.putString(Constants.PREF_ACCESS_TOKEN, newAccessToken)
.putString(Constants.PREF_REFRESH_TOKEN, newRefreshToken)
.putLong(Constants.PREF_TOKEN_EXPIRES_AT, expiresAt)
.commit()
// Cache the new token
cachedToken = newAccessToken
cacheValidUntil = System.currentTimeMillis() + 30_000L
// Notify all pending callbacks
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
while (pendingCallbacks.isNotEmpty()) {
pendingCallbacks.poll()?.let { callbacks.add(it) }
}
isRefreshing = false
onToken(newAccessToken)
callbacks.forEach { (tokenCallback, _) ->
tokenCallback(newAccessToken)
}
} else {
Log.e(TAG, "Token refresh response missing access_token")
clearTokens()
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
while (pendingCallbacks.isNotEmpty()) {
pendingCallbacks.poll()?.let { callbacks.add(it) }
}
isRefreshing = false
onFailure?.invoke()
callbacks.forEach { (_, failureCallback) ->
failureCallback?.invoke()
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception parsing token refresh response", e)
clearTokens()
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
while (pendingCallbacks.isNotEmpty()) {
pendingCallbacks.poll()?.let { callbacks.add(it) }
}
isRefreshing = false
onFailure?.invoke()
callbacks.forEach { (_, failureCallback) ->
failureCallback?.invoke()
}
}
},
{ error ->
Log.e(TAG, "Token refresh request failed: ${error.message}")
clearTokens()
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
while (pendingCallbacks.isNotEmpty()) {
pendingCallbacks.poll()?.let { callbacks.add(it) }
}
isRefreshing = false
onFailure?.invoke()
callbacks.forEach { (_, failureCallback) ->
failureCallback?.invoke()
}
}
) {
override fun getParams(): MutableMap<String, String> =
mutableMapOf(
"grant_type" to "refresh_token",
"client_id" to clientId,
"refresh_token" to refreshToken
)
}
queue.add(request)
}
companion object {
@Volatile
private var INSTANCE: AuthManager? = null
fun getInstance(context: Context, prefs: SharedPreferences): AuthManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: AuthManager(context.applicationContext, prefs).also { INSTANCE = it }
}
}
}
}

View File

@@ -1,126 +0,0 @@
package ml.bmlzootown.hydravion.authenticate;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.Toast;
import androidx.annotation.Nullable;
import com.android.volley.VolleyError;
import com.google.android.material.textfield.TextInputEditText;
import com.google.gson.Gson;
import java.util.ArrayList;
import java.util.UUID;
import ml.bmlzootown.hydravion.R;
import ml.bmlzootown.hydravion.browse.MainFragment;
import ml.bmlzootown.hydravion.models.LoginResponse;
public class LoginActivity extends Activity {
private TextInputEditText username;
private TextInputEditText password;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
username = findViewById(R.id.username);
password = findViewById(R.id.password);
}
public void login(@Nullable View view) {
if (username != null && password != null) {
if (username.length() > 0 || password.length() > 0) {
String user = username.getText().toString();
String pass = password.getText().toString();
hideSoftKeyboard(view);
doLogin(user, pass);
} else {
Toast.makeText(this, "Incorrect username/password!", Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(this, "Incorrect username/password!", Toast.LENGTH_SHORT).show();
}
}
private void doLogin(String username, String password) {
LoginRequestTask rt = new LoginRequestTask(this.getApplicationContext());
rt.sendRequest(username, password, new LoginRequestTask.VolleyCallback() {
@Override
public void onSuccess(ArrayList<String> cookies, String response) {
if (response.length() < 19) {
MainFragment.dLog("RESPONSE", "Possible 2FA");
Gson gson = new Gson();
LoginResponse res = gson.fromJson(response, LoginResponse.class);
if (res.getNeeds2FA()) {
MainFragment.dLog("RESPONSE", "NEEDS 2FA");
AlertDialog.Builder builder = new AlertDialog.Builder(LoginActivity.this);
builder.setTitle("2FA Token");
final EditText input = new EditText(LoginActivity.this);
input.setInputType(InputType.TYPE_CLASS_TEXT);
builder.setView(input);
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
String token = input.getText().toString();
LoginRequestTask twoFA = new LoginRequestTask(LoginActivity.this);
twoFA.sendRequest(token, cookies, new LoginRequestTask.TwoFACallback() {
@Override
public void onSuccess(ArrayList<String> string) {
String sailssid = string.get(0);
ArrayList<String> newCookies = new ArrayList<>();
for (String c : cookies) {
if (!c.contains("sails.sid")) {
newCookies.add(c);
}
}
newCookies.add(sailssid);
Intent intent = new Intent();
intent.putStringArrayListExtra("cookies", newCookies);
setResult(1, intent);
finish();
}
@Override
public void onError(VolleyError ve) {
Toast.makeText(LoginActivity.this, "Incorrect 2FA token!", Toast.LENGTH_SHORT).show();
}
});
}
});
builder.show();
}
} else {
Intent intent = new Intent();
intent.putStringArrayListExtra("cookies", cookies);
setResult(1, intent);
finish();
}
MainFragment.dLog("response", response);
}
@Override
public void onError() {
Toast.makeText(getApplicationContext(), "Incorrect username/password!", Toast.LENGTH_SHORT).show();
}
});
}
public void hideSoftKeyboard(View view) {
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
}

View File

@@ -1,130 +0,0 @@
package ml.bmlzootown.hydravion.authenticate;
import android.content.Context;
import com.android.volley.Header;
import com.android.volley.NetworkResponse;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.StringRequest;
import com.android.volley.toolbox.Volley;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class LoginRequestTask {
private Context context;
private ArrayList<String> cookies;
private static final String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
private static final String userAgent = String.format("Hydravion %s (AndroidTV), CFNetwork", version);
public LoginRequestTask(Context context) {
this.context = context;
}
public void sendRequest(String user, String pass, final LoginRequestTask.VolleyCallback callback) {
String uri = "https://www.floatplane.com/api/auth/login";
RequestQueue queue = Volley.newRequestQueue(this.context);
StringRequest stringRequest = new StringRequest(Request.Method.POST, uri,
response -> callback.onSuccess(cookies, response), error -> {
error.printStackTrace();
callback.onError();
}) {
@Override
protected Response<String> parseNetworkResponse(NetworkResponse response) {
List<Header> headers = response.allHeaders;
ArrayList<String> cs = new ArrayList<>();
for (Header header : headers) {
if (header.getName().equalsIgnoreCase("Set-Cookie")) {
cs.add(header.getValue().split(";")[0]);
}
}
cookies = cs;
return super.parseNetworkResponse(response);
}
@Override
public Map<String, String> getHeaders() {
Map<String, String> params = new HashMap<>();
params.put("Accept", "application/json");
params.put("User-Agent", userAgent);
return params;
}
@Override
protected Map<String, String> getParams() {
Map<String, String> params = new HashMap<>();
params.put("username", user);
params.put("password", pass);
return params;
}
};
queue.add(stringRequest);
}
public void sendRequest(String token, ArrayList<String> cookiez, final LoginRequestTask.TwoFACallback callback) {
String uri = "https://www.floatplane.com/api/v2/auth/checkFor2faLogin";
RequestQueue queue = Volley.newRequestQueue(this.context);
StringRequest stringRequest = new StringRequest(Request.Method.POST, uri,
response -> callback.onSuccess(cookies), error -> {
error.printStackTrace();
callback.onError(error);
}) {
@Override
protected Response<String> parseNetworkResponse(NetworkResponse response) {
List<Header> headers = response.allHeaders;
ArrayList<String> cs = new ArrayList<>();
for (Header header : headers) {
if (header.getName().equalsIgnoreCase("Set-Cookie")) {
cs.add(header.getValue().split(";")[0]);
}
}
cookies = cs;
return super.parseNetworkResponse(response);
}
@Override
public Map<String, String> getHeaders() {
Map<String, String> params = new HashMap<>();
StringBuilder cs = new StringBuilder();
for (String c : cookiez) {
cs.append(c).append(";");
}
params.put("Cookie", cs.toString());
params.put("User-Agent", userAgent);
params.put("Accept", "application/json");
return params;
}
@Override
protected Map<String, String> getParams() {
Map<String, String> params = new HashMap<>();
params.put("token", token);
return params;
}
};
queue.add(stringRequest);
}
public interface TwoFACallback {
void onSuccess(ArrayList<String> string);
void onError(VolleyError ve);
}
public interface VolleyCallback {
void onSuccess(ArrayList<String> string, String response);
void onError();
}
}

View File

@@ -20,14 +20,15 @@ public class LogoutRequestTask {
private Context context;
private static final String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
private static final String userAgent = String.format("Hydravion %s (AndroidTV), CFNetwork", version);
private static final String userAgent = String.format("Hydravion %s (AndroidTV)", version);
public LogoutRequestTask(Context context) {
this.context = context;
}
public void logout(String cookies, final LogoutRequestTask.VolleyCallback callback) {
String uri = "https://www.floatplane.com/api/auth/logout";
public void logout(String accessToken, final LogoutRequestTask.VolleyCallback callback) {
// Revoke access token via revocation endpoint
String uri = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/revoke";
RequestQueue queue = Volley.newRequestQueue(this.context);
StringRequest stringRequest = new StringRequest(Request.Method.POST, uri,
callback::onSuccess, error -> {
@@ -37,11 +38,18 @@ public class LogoutRequestTask {
@Override
public Map<String, String> getHeaders() {
Map<String, String> params = new HashMap<>();
params.put("Cookie", cookies);
params.put("Accept", "application/json");
params.put("User-Agent", userAgent);
return params;
}
@Override
protected Map<String, String> getParams() {
Map<String, String> params = new HashMap<>();
params.put("client_id", "hydravion");
params.put("token", accessToken);
return params;
}
};
queue.add(stringRequest);

View File

@@ -0,0 +1,390 @@
package ml.bmlzootown.hydravion.authenticate;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.StyleSpan;
import android.util.Log;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.journeyapps.barcodescanner.BarcodeEncoder;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.StringRequest;
import com.android.volley.toolbox.Volley;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.Map;
import ml.bmlzootown.hydravion.R;
import ml.bmlzootown.hydravion.browse.MainFragment;
public class QrLoginActivity extends Activity {
private static final String TAG = "QrLoginActivity";
private static final String CLIENT_ID = "hydravion";
private static final String SCOPE = "openid videos:watch account:read";
private static final String DEVICE_AUTH_ENDPOINT = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device";
private static final String TOKEN_ENDPOINT = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token";
private ImageView qrCodeView;
private TextView statusTextView;
private TextView userCodeDisplay;
private TextView instructionsText;
private TextView cookieSteps;
private TextView timerText;
private RequestQueue requestQueue;
private String deviceCode;
private String userCode;
private String verificationUri;
private String verificationUriComplete;
private long expiresAtMs;
private long pollIntervalMs = 5000L;
private android.os.Handler handler;
private final Runnable pollRunnable = new Runnable() {
@Override
public void run() {
pollForToken();
}
};
private final Runnable timerRunnable = new Runnable() {
@Override
public void run() {
updateTimer();
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_qr_login);
qrCodeView = findViewById(R.id.qr_code);
statusTextView = findViewById(R.id.status_text);
userCodeDisplay = findViewById(R.id.user_code_display);
instructionsText = findViewById(R.id.instructions_text);
cookieSteps = findViewById(R.id.cookie_steps);
timerText = findViewById(R.id.timer_text);
requestQueue = Volley.newRequestQueue(this);
handler = new android.os.Handler();
startDeviceAuthorization();
}
private void startDeviceAuthorization() {
statusTextView.setText("Requesting login code...");
StringRequest request = new StringRequest(
Request.Method.POST,
DEVICE_AUTH_ENDPOINT,
this::handleDeviceAuthResponse,
error -> {
Log.e(TAG, "Device authorization error", error);
Toast.makeText(this, "Failed to start device login.", Toast.LENGTH_LONG).show();
finish();
}
) {
@Override
protected Map<String, String> getParams() {
Map<String, String> params = new HashMap<>();
params.put("client_id", CLIENT_ID);
params.put("scope", SCOPE);
return params;
}
};
requestQueue.add(request);
}
private void handleDeviceAuthResponse(String response) {
try {
JSONObject json = new JSONObject(response);
deviceCode = json.getString("device_code");
userCode = json.getString("user_code");
verificationUri = json.getString("verification_uri");
verificationUriComplete = json.optString("verification_uri_complete", verificationUri);
long expiresIn = json.optLong("expires_in", 600L);
long interval = json.optLong("interval", 5L);
expiresAtMs = System.currentTimeMillis() + expiresIn * 1000L;
pollIntervalMs = interval * 1000L;
// Generate QR code with verification_uri_complete (on-device, no external service)
generateQRCode(verificationUriComplete);
// Display user code in highlighted area
userCodeDisplay.setText(userCode);
// Update instructions text with bold floatplane.com/link
updateInstructionsWithBoldUrl();
statusTextView.setText("Waiting for you to complete login on another device...");
// Start timer countdown
handler.post(timerRunnable);
// Start polling token endpoint
handler.postDelayed(pollRunnable, pollIntervalMs);
} catch (JSONException e) {
Log.e(TAG, "Failed to parse device auth response", e);
Toast.makeText(this, "Invalid response from login server.", Toast.LENGTH_LONG).show();
finish();
}
}
private void generateQRCode(String url) {
try {
// Generate QR code on-device using local library (no external service)
BarcodeEncoder barcodeEncoder = new BarcodeEncoder();
// Generate bitmap - use size that matches view (280dp = ~840px at 3x density)
Bitmap originalBitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, 840, 840);
// Crop white borders to remove quiet zone padding
Bitmap croppedBitmap = cropWhiteBorders(originalBitmap);
// Recycle the original large bitmap if a new cropped bitmap was created
if (croppedBitmap != originalBitmap && !originalBitmap.isRecycled()) {
originalBitmap.recycle();
}
qrCodeView.setImageBitmap(croppedBitmap);
} catch (WriterException e) {
Log.e(TAG, "Error generating QR code", e);
Toast.makeText(this, "Failed to generate QR code", Toast.LENGTH_SHORT).show();
}
}
private Bitmap cropWhiteBorders(Bitmap bitmap) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
// Find top border
int top = 0;
for (int y = 0; y < height; y++) {
boolean hasNonWhite = false;
for (int x = 0; x < width; x++) {
int pixel = bitmap.getPixel(x, y);
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
hasNonWhite = true;
break;
}
}
if (hasNonWhite) {
top = y;
break;
}
}
// Find bottom border
int bottom = height - 1;
for (int y = height - 1; y >= 0; y--) {
boolean hasNonWhite = false;
for (int x = 0; x < width; x++) {
int pixel = bitmap.getPixel(x, y);
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
hasNonWhite = true;
break;
}
}
if (hasNonWhite) {
bottom = y;
break;
}
}
// Find left border
int left = 0;
for (int x = 0; x < width; x++) {
boolean hasNonWhite = false;
for (int y = top; y <= bottom; y++) {
int pixel = bitmap.getPixel(x, y);
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
hasNonWhite = true;
break;
}
}
if (hasNonWhite) {
left = x;
break;
}
}
// Find right border
int right = width - 1;
for (int x = width - 1; x >= 0; x--) {
boolean hasNonWhite = false;
for (int y = top; y <= bottom; y++) {
int pixel = bitmap.getPixel(x, y);
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
hasNonWhite = true;
break;
}
}
if (hasNonWhite) {
right = x;
break;
}
}
// Crop to actual QR code bounds
if (left < right && top < bottom) {
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1);
}
return bitmap;
}
private void updateTimer() {
if (expiresAtMs == 0) {
return;
}
long remainingMs = expiresAtMs - System.currentTimeMillis();
if (remainingMs <= 0) {
// Timer expired, refresh the QR code and user code
timerText.setText("Time Remaining: 0:00");
statusTextView.setText("Login code expired. Refreshing...");
handler.removeCallbacks(pollRunnable);
handler.removeCallbacks(timerRunnable);
startDeviceAuthorization();
return;
}
long remainingSeconds = remainingMs / 1000L;
long minutes = remainingSeconds / 60L;
long seconds = remainingSeconds % 60L;
String timerString = String.format("Time Remaining: %d:%02d", minutes, seconds);
timerText.setText(timerString);
// Update timer every second
handler.postDelayed(timerRunnable, 1000L);
}
private void updateInstructionsWithBoldUrl() {
// Bold "floatplane.com/link" in instructions text
String instructions = instructionsText.getText().toString();
SpannableString spannable = new SpannableString(instructions);
int start = instructions.indexOf("floatplane.com/link");
if (start >= 0) {
int end = start + "floatplane.com/link".length();
spannable.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
instructionsText.setText(spannable);
// Bold "floatplane.com/link" in steps text
String steps = cookieSteps.getText().toString();
SpannableString stepsSpannable = new SpannableString(steps);
int stepsStart = steps.indexOf("floatplane.com/link");
if (stepsStart >= 0) {
int stepsEnd = stepsStart + "floatplane.com/link".length();
stepsSpannable.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), stepsStart, stepsEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
cookieSteps.setText(stepsSpannable);
}
private void pollForToken() {
if (deviceCode == null) {
return;
}
if (System.currentTimeMillis() >= expiresAtMs) {
// Timer will handle the refresh automatically
return;
}
StringRequest request = new StringRequest(
Request.Method.POST,
TOKEN_ENDPOINT,
this::handleTokenResponse,
error -> {
Log.e(TAG, "Token polling error", error);
// Keep polling on transient errors
handler.postDelayed(pollRunnable, pollIntervalMs);
}
) {
@Override
protected Map<String, String> getParams() {
Map<String, String> params = new HashMap<>();
params.put("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
params.put("client_id", CLIENT_ID);
params.put("device_code", deviceCode);
return params;
}
};
requestQueue.add(request);
}
private void handleTokenResponse(String response) {
try {
JSONObject json = new JSONObject(response);
if (json.has("error")) {
String error = json.getString("error");
if ("authorization_pending".equals(error)) {
// keep polling
handler.postDelayed(pollRunnable, pollIntervalMs);
return;
} else if ("slow_down".equals(error)) {
pollIntervalMs += 2000L;
handler.postDelayed(pollRunnable, pollIntervalMs);
return;
} else if ("expired_token".equals(error)) {
statusTextView.setText("Login code expired. Please try again.");
return;
} else {
statusTextView.setText("Login failed: " + error);
return;
}
}
String accessToken = json.getString("access_token");
String refreshToken = json.optString("refresh_token", "");
long expiresIn = json.optLong("expires_in", 3600L);
handleLoginSuccess(accessToken, refreshToken, expiresIn);
} catch (JSONException e) {
Log.e(TAG, "Failed to parse token response", e);
statusTextView.setText("Failed to complete login.");
}
}
private void handleLoginSuccess(String accessToken, String refreshToken, long expiresInSeconds) {
// Stop all handlers before finishing
if (handler != null) {
handler.removeCallbacks(pollRunnable);
handler.removeCallbacks(timerRunnable);
}
Intent intent = new Intent();
intent.putExtra("access_token", accessToken);
intent.putExtra("refresh_token", refreshToken);
intent.putExtra("expires_in", expiresInSeconds);
setResult(RESULT_OK, intent);
finish();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (handler != null) {
handler.removeCallbacks(pollRunnable);
handler.removeCallbacks(timerRunnable);
}
}
}

View File

@@ -51,7 +51,7 @@ import kotlin.Unit;
import ml.bmlzootown.hydravion.BuildConfig;
import ml.bmlzootown.hydravion.Constants;
import ml.bmlzootown.hydravion.R;
import ml.bmlzootown.hydravion.authenticate.LoginActivity;
import ml.bmlzootown.hydravion.authenticate.AuthManager;
import ml.bmlzootown.hydravion.authenticate.LogoutRequestTask;
import ml.bmlzootown.hydravion.card.CardPresenter;
import ml.bmlzootown.hydravion.client.HydravionClient;
@@ -84,8 +84,6 @@ public class MainFragment extends BrowseSupportFragment {
private Socket socket;
private final Gson gson = new Gson();
public static String sailssid;
public static List<Subscription> subscriptions = new ArrayList<>();
private static NavigableMap<Integer, Video> strms = new TreeMap<>();
public static HashMap<String, ArrayList<Video>> videos = new HashMap<>();
@@ -99,6 +97,10 @@ public class MainFragment extends BrowseSupportFragment {
private final Handler liveHandler = new Handler(Looper.getMainLooper());
private int liveIndex = -1;
private boolean backgroundManagerPrepared = false;
private boolean uiInitialized = false;
private boolean isLoggedIn = false;
private boolean adapterInitialized = false;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
@@ -128,32 +130,49 @@ public class MainFragment extends BrowseSupportFragment {
}
private void checkLogin() {
boolean gotCookies = loadCredentials();
if (!gotCookies) {
Intent intent = new Intent(getActivity(), LoginActivity.class);
AuthManager authManager = AuthManager.Companion.getInstance(requireActivity(), requireActivity().getPreferences(Context.MODE_PRIVATE));
authManager.withValidAccessToken(accessToken -> {
dLog("LOGIN", "Access token valid (or refreshed successfully)");
isLoggedIn = true;
initialize();
return Unit.INSTANCE;
}, () -> {
dLog("LOGIN", "No valid access token or refresh token available. Starting login flow.");
isLoggedIn = false;
Intent intent = new Intent(getActivity(), ml.bmlzootown.hydravion.authenticate.QrLoginActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
startActivityForResult(intent, 42);
} else {
initialize();
}
return Unit.INSTANCE;
});
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == 42 && resultCode == 1 && data != null) {
ArrayList<String> cookies = data.getStringArrayListExtra("cookies");
for (String cookie : cookies) {
String[] c = cookie.split("=");
if (c[0].equalsIgnoreCase("sails.sid")) {
sailssid = c[1];
}
}
dLog("MainFragment", sailssid);
if (requestCode == 42 && resultCode == RESULT_OK && data != null) {
String accessToken = data.getStringExtra("access_token");
String refreshToken = data.getStringExtra("refresh_token");
long expiresIn = data.getLongExtra("expires_in", 3600L);
saveCredentials();
initialize();
if (accessToken != null && !accessToken.isEmpty()) {
long expiresAt = System.currentTimeMillis() + (expiresIn * 1000L);
// Use empty string if refreshToken is null to avoid NullPointerException
String safeRefreshToken = (refreshToken != null) ? refreshToken : "";
requireActivity().getPreferences(Context.MODE_PRIVATE).edit()
.putString(Constants.PREF_ACCESS_TOKEN, accessToken)
.putString(Constants.PREF_REFRESH_TOKEN, safeRefreshToken)
.putLong(Constants.PREF_TOKEN_EXPIRES_AT, expiresAt)
.commit();
// Mark as logged in and initialize
isLoggedIn = true;
initialize();
} else {
dLog(TAG, "Login result missing access token; restarting login flow.");
// Restart login flow to avoid running without credentials
checkLogin();
}
} else if (requestCode == Constants.REQ_CODE_DETAIL && resultCode == RESULT_OK && data != null) {
if (data.getBooleanExtra("REFRESH", false)) {
refreshVideoProgress();
@@ -164,18 +183,31 @@ public class MainFragment extends BrowseSupportFragment {
private void initialize() {
refreshSubscriptions();
prepareBackgroundManager();
setupUIElements();
setupEventListeners();
// Only setup UI elements and listeners once, before views are created
if (!uiInitialized) {
setupUIElements();
setupEventListeners();
uiInitialized = true;
}
// TODO: Temporarily disabled - backend doesn't support auth tokens with websockets yet
// Setup Socket
setupSocket();
// setupSocket();
}
private void setupSocket() {
socket = socketClient.initialize();
socket.on("connect", onSocketConnect);
socket.on("disconnect", onSocketDisconnect);
socket.on("syncEvent", onSyncEvent);
socketClient.initialize(sock -> {
if (sock == null) {
dLog("SOCKET", "Failed to initialize socket due to auth error");
return Unit.INSTANCE;
}
socket = sock;
socket.on("connect", onSocketConnect);
socket.on("disconnect", onSocketDisconnect);
socket.on("syncEvent", onSyncEvent);
return Unit.INSTANCE;
});
}
// Socket Event Emitters
@@ -202,7 +234,8 @@ public class MainFragment extends BrowseSupportFragment {
private final Emitter.Listener onSocketDisconnect = args -> {
dLog("SOCKET", "Disconnected");
setupSocket();
// TODO: Temporarily disabled - backend doesn't support auth tokens with websockets yet
// setupSocket();
};
private final Emitter.Listener onSyncEvent = args -> {
@@ -243,52 +276,87 @@ public class MainFragment extends BrowseSupportFragment {
dLog("SOCKET --> SYNCEVENT", event.toString());
};
private boolean loadCredentials() {
SharedPreferences prefs = requireActivity().getPreferences(Context.MODE_PRIVATE);
sailssid = prefs.getString(Constants.PREF_SAIL_SSID, "default");
dLog("SAILS.SID", sailssid);
if (sailssid.equals("default")) {
dLog("LOGIN", "Credentials not found!");
return false;
} else {
dLog("LOGIN", "Credentials found!");
return true;
}
}
private void logout() {
// Invalidate cookies via API
LogoutRequestTask lrt = new LogoutRequestTask(getContext());
String cookies = "sails.sid=" + sailssid + ";";
lrt.logout(cookies, new LogoutRequestTask.VolleyCallback() {
@Override
public void onSuccess(String response) {
dLog("LOGOUT", "Success!");
}
SharedPreferences prefs = requireActivity().getPreferences(Context.MODE_PRIVATE);
String accessToken = prefs.getString(Constants.PREF_ACCESS_TOKEN, null);
@Override
public void onError(VolleyError error) {
dLog("LOGOUT --> ERROR", error.getMessage());
}
});
// Best-effort token revocation; ignore errors
if (accessToken != null && !accessToken.isEmpty()) {
LogoutRequestTask lrt = new LogoutRequestTask(getContext());
lrt.logout(accessToken, new LogoutRequestTask.VolleyCallback() {
@Override
public void onSuccess(String response) {
dLog("LOGOUT", "Token revoked");
}
// Removed cookies, save dummy cookies, and close client
sailssid = "default";
saveCredentials();
requireActivity().finishAndRemoveTask();
}
@Override
public void onError(VolleyError error) {
dLog("LOGOUT", "Revocation failed: " + error.getMessage());
}
});
}
private void saveCredentials() {
requireActivity().getPreferences(Context.MODE_PRIVATE).edit()
.putString(Constants.PREF_SAIL_SSID, sailssid)
.apply();
// Clear tokens and in-memory data
// Use AuthManager to clear both SharedPreferences and in-memory cache
AuthManager authManager = AuthManager.Companion.getInstance(requireActivity(), prefs);
authManager.clearTokens();
// Clear all in-memory data structures
subscriptions.clear();
videos.clear();
strms.clear();
videoProgress.clear();
// Reset state variables
subCount = 0;
page = 1;
rowSelected = 0;
colSelected = 0;
liveIndex = -1;
// Remove any pending live handler callbacks to prevent accessing cleared data
liveHandler.removeCallbacksAndMessages(null);
// Disconnect socket if connected
if (socket != null && socket.connected()) {
socket.disconnect();
socket.off();
socket = null;
}
// Clear the adapter to prevent stale data from being displayed
if (getAdapter() != null) {
setAdapter(null);
}
// Reset adapter initialization flag
adapterInitialized = false;
// Reset UI initialization flag to allow proper setup on next login
uiInitialized = false;
// Mark as logged out to prevent callbacks from updating UI
isLoggedIn = false;
// Restart the QR login flow instead of closing the app
checkLogin();
}
private void gotLiveInfo(Subscription sub, Delivery live) {
// Guard against processing live info if we're logged out
if (!isLoggedIn) {
dLog(TAG, "Ignoring live info update - user is logged out");
return;
}
String l = live.getGroups().get(0).getOrigins().get(0).getUrl() + live.getGroups().get(0).getVariants().get(0).getUrl();
sub.setStreamUrl(l);
client.checkLive(l, (status) -> {
// Double-check we're still logged in when callback executes
if (!isLoggedIn) {
dLog(TAG, "Ignoring live status callback - user logged out during request");
return Unit.INSTANCE;
}
sub.setStreaming(status == 200);
dLog("LIVE STATUS", String.valueOf(status));
return Unit.INSTANCE;
@@ -301,12 +369,14 @@ public class MainFragment extends BrowseSupportFragment {
if (subscriptions == null) {
new AlertDialog.Builder(getContext())
.setTitle("Session Expired")
.setMessage("Re-open Hydravion to login again!")
.setPositiveButton("OK",
.setMessage("Your Floatplane session has expired. Please relink your account.")
.setPositiveButton("Relink",
(dialog, which) -> {
dialog.dismiss();
logout();
})
.setNegativeButton("Cancel",
(dialog, which) -> dialog.dismiss())
.create()
.show();
} else {
@@ -330,6 +400,12 @@ public class MainFragment extends BrowseSupportFragment {
}
private void gotSubscriptions(Subscription[] subs) {
// Guard against processing subscriptions if we're logged out
if (!isLoggedIn) {
dLog(TAG, "Ignoring subscription update - user is logged out");
return;
}
List<Subscription> trimmed = new ArrayList<>();
for (Subscription sub : subs) {
if (trimmed.size() > 0) {
@@ -368,23 +444,55 @@ public class MainFragment extends BrowseSupportFragment {
}
private void gotVideos(String creatorGUID, Video[] vids) {
// Guard against processing videos if we're logged out
if (!isLoggedIn) {
dLog(TAG, "Ignoring video update - user is logged out");
return;
}
boolean isPagination = adapterInitialized && videos.get(creatorGUID) != null && videos.get(creatorGUID).size() > 0;
int previousSize = (videos.get(creatorGUID) != null) ? videos.get(creatorGUID).size() : 0;
if (videos.get(creatorGUID) != null && videos.get(creatorGUID).size() > 0) {
videos.get(creatorGUID).addAll(Arrays.asList(vids));
} else {
videos.put(creatorGUID, new ArrayList<>(Arrays.asList(vids)));
}
if (subCount > 1) {
subCount--;
if (isPagination) {
// For pagination, append videos immediately for this creator without waiting for others
appendVideosToRows(creatorGUID, previousSize);
// Still track subCount for coordination, but don't block on it
if (subCount > 1) {
subCount--;
} else {
subCount = subscriptions.size();
}
} else {
refreshVideoProgress();
subCount = subscriptions.size();
setSelectedPosition(rowSelected, false, new ListRowPresenter.SelectItemViewHolderTask(colSelected));
// Initial load - wait for all subscriptions to finish, then do full refresh
if (subCount > 1) {
subCount--;
} else {
refreshVideoProgress();
subCount = subscriptions.size();
setSelectedPosition(rowSelected, false, new ListRowPresenter.SelectItemViewHolderTask(colSelected));
}
}
}
private void refreshVideoProgress() {
// Guard against refreshing progress if we're logged out
if (!isLoggedIn) {
dLog(TAG, "Ignoring video progress refresh - user is logged out");
return;
}
client.getVideoProgress(MapExtensionKt.getBlogPostIdsFromCreatorMap(videos), progress -> {
// Double-check we're still logged in when callback executes
if (!isLoggedIn) {
dLog(TAG, "Ignoring video progress callback - user logged out during request");
return Unit.INSTANCE;
}
videoProgress = progress;
refreshRows();
return Unit.INSTANCE;
@@ -392,6 +500,12 @@ public class MainFragment extends BrowseSupportFragment {
}
private void refreshRows() {
// Guard against refreshing rows if we're logged out
if (!isLoggedIn) {
dLog(TAG, "Ignoring row refresh - user is logged out");
return;
}
List<Subscription> subs = subscriptions;
ArrayObjectAdapter rowsAdapter = new ArrayObjectAdapter(new ListRowPresenter());
CardPresenter cardPresenter = new CardPresenter(videoProgress);
@@ -468,6 +582,7 @@ public class MainFragment extends BrowseSupportFragment {
rowsAdapter.add(new ListRow(gridHeader, gridRowAdapter));
setAdapter(rowsAdapter);
adapterInitialized = true;
}
private void addLiveToRow(Integer row, Video stream, List<Subscription> subs) {
@@ -516,6 +631,63 @@ public class MainFragment extends BrowseSupportFragment {
liveHandler.post(runnable);
}
private void appendVideosToRows(String creatorGUID, int previousSize) {
// Guard against appending if we're logged out
if (!isLoggedIn) {
dLog(TAG, "Ignoring video append - user is logged out");
return;
}
ArrayObjectAdapter rowsAdapter = (ArrayObjectAdapter) getAdapter();
if (rowsAdapter == null) {
dLog(TAG, "Adapter not initialized, falling back to full refresh");
refreshVideoProgress();
return;
}
int rowIndex = getRow(creatorGUID, subscriptions);
if (rowIndex == -1 || rowIndex >= rowsAdapter.size()) {
dLog(TAG, "Invalid row index for creator: " + creatorGUID);
return;
}
ListRow listRow = (ListRow) rowsAdapter.get(rowIndex);
if (listRow == null) {
dLog(TAG, "ListRow is null for row index: " + rowIndex);
return;
}
ArrayObjectAdapter listRowAdapter = (ArrayObjectAdapter) listRow.getAdapter();
if (listRowAdapter == null) {
dLog(TAG, "ListRow adapter is null for row index: " + rowIndex);
return;
}
List<Video> vids = videos.get(creatorGUID);
if (vids == null || vids.size() <= previousSize) {
dLog(TAG, "No new videos to append for creator: " + creatorGUID);
return;
}
// Get new videos that were added
List<Video> newVideos = vids.subList(previousSize, vids.size());
// Store the insertion position before adding items
int insertPosition = listRowAdapter.size();
// Add new videos to the adapter (add() automatically notifies, but we'll be explicit for the range)
for (Video video : newVideos) {
listRowAdapter.add(video);
}
// Notify adapter of the new items for smooth insertion without full refresh
// Using notifyArrayItemRangeChanged to match existing code pattern
listRowAdapter.notifyArrayItemRangeChanged(insertPosition, newVideos.size());
// Selection position should be automatically preserved since we're not calling setAdapter()
dLog(TAG, "Appended " + newVideos.size() + " videos to row " + rowIndex + " starting at position " + insertPosition);
}
private void addToRow(Video video, List<Subscription> subs) {
dLog("addToRow", video.getGuid());
for (int i = 0; i < subs.size(); i++) {
@@ -565,11 +737,24 @@ public class MainFragment extends BrowseSupportFragment {
}
private void prepareBackgroundManager() {
BackgroundManager mBackgroundManager = BackgroundManager.getInstance(requireActivity());
mBackgroundManager.attach(requireActivity().getWindow());
DisplayMetrics mMetrics = new DisplayMetrics();
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
// BackgroundManager is a singleton per activity, so we need to avoid attaching multiple times
if (backgroundManagerPrepared) {
dLog(TAG, "BackgroundManager already prepared, skipping");
return;
}
try {
BackgroundManager mBackgroundManager = BackgroundManager.getInstance(requireActivity());
mBackgroundManager.attach(requireActivity().getWindow());
backgroundManagerPrepared = true;
DisplayMetrics mMetrics = new DisplayMetrics();
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
} catch (IllegalStateException e) {
// BackgroundManager is already attached, which is fine
dLog(TAG, "BackgroundManager already attached: " + e.getMessage());
backgroundManagerPrepared = true;
}
}
@SuppressLint("UseCompatLoadingForDrawables")
@@ -650,6 +835,7 @@ public class MainFragment extends BrowseSupportFragment {
switch (action) {
case REFRESH:
videos.clear();
adapterInitialized = false; // Reset flag on manual refresh
refreshSubscriptions(); // Refresh will get subs and videos again, then refresh row UI
break;
case LOGOUT:

View File

@@ -129,7 +129,7 @@ class CardPresenter(private val videoProgress: List<VideoProgress>) : Presenter(
.load(
GlideUrl(
thumbnail, LazyHeaders.Builder()
.addHeader("User-Agent", "Hydravion (AndroidTV $version), CFNetwork")
.addHeader("User-Agent", "Hydravion (AndroidTV $version)")
.build()
)
)

View File

@@ -8,6 +8,7 @@ import com.google.gson.Gson
import ml.bmlzootown.hydravion.BuildConfig
import ml.bmlzootown.hydravion.Constants
import ml.bmlzootown.hydravion.browse.MainFragment
import ml.bmlzootown.hydravion.authenticate.AuthManager
import ml.bmlzootown.hydravion.creator.Creator
import ml.bmlzootown.hydravion.creator.FloatplaneLiveStream
import ml.bmlzootown.hydravion.github.Release
@@ -23,16 +24,11 @@ class HydravionClient private constructor(private val context: Context, private
private val creatorIds: MutableMap<String, String> = hashMapOf()
private val creatorCache: MutableMap<String, Creator> = hashMapOf()
private val requestTask: RequestTask = RequestTask(context)
/**
* Convenience fun to get cookies string
* @return Cookies string
*/
private fun getCookiesString(): String =
"${Constants.PREF_SAIL_SSID}=${mainPrefs.getString(Constants.PREF_SAIL_SSID, "")}"
private val authManager: AuthManager = AuthManager.getInstance(context, mainPrefs)
fun getSubs(callback: (Array<Subscription>?) -> Unit) {
requestTask.sendRequest(URI_SUBSCRIPTIONS, getCookiesString(), object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(URI_SUBSCRIPTIONS, token, object : RequestTask.VolleyCallback {
override fun onResponseCode(response: Int) {
//Ignore
@@ -70,23 +66,26 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = callback(null)
})
}, {
callback(null)
})
}
fun getCreatorInfo(creatorGUID: String, callback: (FloatplaneLiveStream) -> Unit) {
requestTask.sendRequest(
"$URI_CREATOR_INFO?creatorGUID=$creatorGUID",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_CREATOR_INFO?id=$creatorGUID",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
if (BuildConfig.DEBUG) {
MainFragment.dLog(TAG,"getCreatorInfo: $response")
}
try {
JSONArray(response).getString(0)?.let {
Gson().fromJson(it, Creator::class.java).let { creator ->
creator.lastLiveStream?.let { it1 -> callback.invoke(it1) }
}
// v3 API returns a single object, not an array
Gson().fromJson(response, Creator::class.java).let { creator ->
creator.lastLiveStream?.let { it1 -> callback.invoke(it1) }
}
} catch (e: Exception) {
e.printStackTrace()
@@ -99,14 +98,18 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op on failure
})
}
fun getVideos(creatorGUID: String, page: Int, callback: (Array<Video>) -> Unit) {
requestTask.sendRequest(
"$URI_VIDEOS?id=$creatorGUID&fetchAfter=${(page - 1) * 20}",
getCookiesString(),
creatorGUID,
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_VIDEOS?id=$creatorGUID&fetchAfter=${(page - 1) * 20}",
token,
creatorGUID,
object : RequestTask.VolleyCallback {
override fun onResponseCode(response: Int) = Unit
@@ -122,14 +125,18 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
callback(emptyArray())
})
}
fun getVideo(video: Video, res: String, callback: (Video) -> Unit) {
//val y = Util.getCurrentDisplayModeSize(context).y;
requestTask.sendRequest(
"$URI_DELIVERY?scenario=onDemand&entityId=${video.getVideoId()}",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_DELIVERY?scenario=onDemand&entityId=${video.getVideoId()}",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
if (BuildConfig.DEBUG) {
@@ -174,14 +181,18 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op
})
}
fun getVideoObject(id: String, callback: (Video) -> Unit) {
requestTask.sendRequest(
"$URI_POST?id=$id",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_POST?id=$id",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
try {
callback(Gson().fromJson(response, Video::class.java))
@@ -196,13 +207,17 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op
})
}
fun getVideoInfo(videoID: String, callback: (VideoInfo) -> Unit) {
requestTask.sendRequest(
"$URI_VIDEO_INFO?id=$videoID",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_VIDEO_INFO?id=$videoID",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
@@ -219,13 +234,17 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op
})
}
fun getLive(sub: Subscription, callback: (Delivery) -> Unit) {
requestTask.sendRequest(
"$URI_CREATOR?id=${sub.creator}",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_CREATOR?id=${sub.creator}",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
val c: Creator = Gson().fromJson(response, Creator::class.java)
@@ -243,13 +262,17 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op
})
}
fun getLive(livestreamID: String, callback: (Delivery) -> Unit) {
requestTask.sendRequest(
"$URI_DELIVERY?scenario=live&entityId=$livestreamID",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_DELIVERY?scenario=live&entityId=$livestreamID",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
callback(Gson().fromJson(response, Delivery::class.java))
@@ -261,6 +284,9 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op
})
}
/*fun getLive(creatorGUID: String, callback: (Live) -> Unit) {
@@ -315,18 +341,18 @@ class HydravionClient private constructor(private val context: Context, private
return
}
requestTask.sendRequest(
"$URI_CREATOR_INFO?creatorGUID=$creatorGUID",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_CREATOR_INFO?id=$creatorGUID",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
try {
JSONArray(response).getString(0)?.let {
Gson().fromJson(it, Creator::class.java).let { creator ->
creatorCache[creatorGUID] = creator
callback?.invoke(creator)
}
// v3 API returns a single object, not an array
Gson().fromJson(response, Creator::class.java).let { creator ->
creatorCache[creatorGUID] = creator
callback?.invoke(creator)
}
} catch (e: Exception) {
e.printStackTrace()
@@ -339,13 +365,17 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
// no-op
})
}
fun getPost(postId: String, callback: (Post) -> Unit) {
requestTask.sendRequest(
"$URI_POST?id=$postId",
getCookiesString(),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendRequest(
"$URI_POST?id=$postId",
token,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
@@ -361,7 +391,10 @@ class HydravionClient private constructor(private val context: Context, private
override fun onSuccessCreator(response: String, creatorGUID: String) = Unit
override fun onError(error: VolleyError) = Unit
});
})
}, {
// no-op
})
}
fun getLatest(callback: (String) -> Unit) {
@@ -385,11 +418,12 @@ class HydravionClient private constructor(private val context: Context, private
}
fun toggleLikePost(postId: String, callback: (Boolean) -> Unit) {
requestTask.sendData(
URI_LIKE,
getCookiesString(),
mapOf("id" to postId, "contentType" to "blogPost"),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendData(
URI_LIKE,
token,
mapOf("id" to postId, "contentType" to "blogPost"),
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
callback(response.contains("like"))
@@ -401,14 +435,18 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
callback(false)
})
}
fun toggleDislikePost(postId: String, callback: (Boolean) -> Unit) {
requestTask.sendData(
URI_DISLIKE,
getCookiesString(),
mapOf("id" to postId, "contentType" to "blogPost"),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendData(
URI_DISLIKE,
token,
mapOf("id" to postId, "contentType" to "blogPost"),
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
callback(response.contains("dislike"))
@@ -420,6 +458,9 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) = Unit
})
}, {
callback(false)
})
}
fun getVideoProgress(blogPostIds: List<String>, callback: (List<VideoProgress>) -> Unit) {
@@ -429,11 +470,12 @@ class HydravionClient private constructor(private val context: Context, private
json.put("contentType", "blogPost")
json
}.toString()
requestTask.sendDataWithBody(
URI_GET_PROGRESS,
getCookiesString(),
body,
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendDataWithBody(
URI_GET_PROGRESS,
token,
body,
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) {
try {
@@ -452,16 +494,19 @@ class HydravionClient private constructor(private val context: Context, private
override fun onError(error: VolleyError) {
callback(ArrayList())
}
}
)
})
}, {
callback(ArrayList())
})
}
fun setVideoProgress(videoId: String, progressInPercent: Int) {
requestTask.sendData(
URI_UPDATE_PROGRESS,
getCookiesString(),
mapOf("id" to videoId, "contentType" to "video", "progress" to progressInPercent.toString()),
object : RequestTask.VolleyCallback {
authManager.withValidAccessToken({ token ->
requestTask.sendData(
URI_UPDATE_PROGRESS,
token,
mapOf("id" to videoId, "contentType" to "video", "progress" to progressInPercent.toString()),
object : RequestTask.VolleyCallback {
override fun onSuccess(response: String) = Unit
@@ -470,8 +515,10 @@ class HydravionClient private constructor(private val context: Context, private
override fun onSuccessCreator(response: String, creatorGUID: String) = Unit
override fun onError(error: VolleyError) = Unit
}
)
})
}, {
// ignore
})
}
companion object {
@@ -479,9 +526,9 @@ class HydravionClient private constructor(private val context: Context, private
private const val TAG = "HydravionClient"
private const val SITE = "https://www.floatplane.com"
// TODO Update to v3 API
private const val URI_SUBSCRIPTIONS = "$SITE/api/user/subscriptions"
private const val URI_CREATOR_INFO = "$SITE/api/creator/info"
// Updated to v3 API
private const val URI_SUBSCRIPTIONS = "$SITE/api/v3/user/subscriptions"
private const val URI_CREATOR_INFO = "$SITE/api/v3/creator/info"
// Already updated!
private const val URI_DELIVERY = "$SITE/api/v3/delivery/info"

View File

@@ -34,7 +34,7 @@ class RequestTask(context: Context) {
volleyQueue.add(stringRequest)
}
fun sendRequest(uri: String?, cookies: String, callback: VolleyCallback) {
fun sendRequest(uri: String?, accessToken: String, callback: VolleyCallback) {
val stringRequest: StringRequest = object : StringRequest(Method.GET, uri,
Response.Listener { response: String? ->
callback.onSuccess(response ?: "")
@@ -42,18 +42,17 @@ class RequestTask(context: Context) {
error.printStackTrace()
callback.onError(error)
}) {
override fun getHeaders(): Map<String, String> {
val params: MutableMap<String, String> = HashMap()
params["Cookie"] = cookies
params["Accept"] = "application/json"
params["User-Agent"] = "Hydravion (AndroidTV $version), CFNetwork"
return params
}
override fun getHeaders(): Map<String, String> =
mapOf(
"Authorization" to "Bearer $accessToken",
"Accept" to "application/json",
"User-Agent" to "Hydravion (AndroidTV $version)"
)
}
volleyQueue.add(stringRequest)
}
fun sendData(uri: String?, cookies: String, params: Map<String, String>?, callback: VolleyCallback) {
fun sendData(uri: String?, accessToken: String, params: Map<String, String>?, callback: VolleyCallback) {
val stringRequest: StringRequest = object : StringRequest(
Method.POST,
uri,
@@ -70,15 +69,15 @@ class RequestTask(context: Context) {
@Throws(AuthFailureError::class)
override fun getHeaders(): Map<String, String> =
mapOf(
"Cookie" to cookies,
"Authorization" to "Bearer $accessToken",
"Accept" to ACCEPT_JSON,
"User-Agent" to "Hydravion (AndroidTV $version), CFNetwork"
"User-Agent" to "Hydravion (AndroidTV $version)"
)
}
volleyQueue.add(stringRequest)
}
fun sendDataWithBody(uri: String?, cookies: String, body: String, callback: VolleyCallback) {
fun sendDataWithBody(uri: String?, accessToken: String, body: String, callback: VolleyCallback) {
val jsonRequest: JsonRequest<String> = object : JsonRequest<String>(
Method.POST,
uri,
@@ -93,9 +92,9 @@ class RequestTask(context: Context) {
@Throws(AuthFailureError::class)
override fun getHeaders(): Map<String, String> =
mapOf(
"Cookie" to cookies,
"Authorization" to "Bearer $accessToken",
"Accept" to ACCEPT_JSON,
"User-Agent" to "Hydravion (AndroidTV $version), CFNetwork",
"User-Agent" to "Hydravion (AndroidTV $version)",
"Content-Type" to "application/json"
)
@@ -107,7 +106,7 @@ class RequestTask(context: Context) {
volleyQueue.add(jsonRequest)
}
fun sendRequest(uri: String?, cookies: String, creatorGUID: String?, callback: VolleyCallback) {
fun sendRequest(uri: String?, accessToken: String, creatorGUID: String?, callback: VolleyCallback) {
val stringRequest: StringRequest = object : StringRequest(
Method.GET,
uri,
@@ -120,9 +119,9 @@ class RequestTask(context: Context) {
override fun getHeaders(): Map<String, String> =
mapOf(
"Cookie" to cookies,
"Authorization" to "Bearer $accessToken",
"Accept" to ACCEPT_JSON,
"User-Agent" to "Hydravion (AndroidTV $version), CFNetwork"
"User-Agent" to "Hydravion (AndroidTV $version)"
)
}
volleyQueue.add(stringRequest)

View File

@@ -11,6 +11,7 @@ import io.socket.client.Socket
import io.socket.engineio.client.Transport
import io.socket.engineio.client.transports.WebSocket
import ml.bmlzootown.hydravion.Constants
import ml.bmlzootown.hydravion.authenticate.AuthManager
import ml.bmlzootown.hydravion.browse.MainFragment
import ml.bmlzootown.hydravion.post.Post
import okhttp3.OkHttpClient
@@ -22,58 +23,53 @@ import java.util.*
class SocketClient private constructor(private val context: Context, private val mainPrefs: SharedPreferences) {
/**
* Convenience fun to get cookies string
* @return Cookies string
*/
val version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME
private val authManager: AuthManager = AuthManager.getInstance(context, mainPrefs)
private fun getCookiesString(): String =
"${Constants.PREF_SAIL_SSID}=${mainPrefs.getString(Constants.PREF_SAIL_SSID, "")}"
// Initialize the WebSocket connection, ensuring we use a fresh access token.
fun initialize(onReady: (Socket?) -> Unit) {
authManager.withValidAccessToken({ token ->
val okHttpClient = OkHttpClient.Builder().build()
IO.setDefaultOkHttpWebSocketFactory(okHttpClient)
IO.setDefaultOkHttpCallFactory(okHttpClient)
fun initialize(): Socket {
val heads = mutableMapOf<String, List<String>>()
heads["Origin"] = listOf("https://www.floatplane.com")
heads["Cookie"] = listOf(getCookiesString())
heads["User-Agent"] = listOf("Hydravion (AndroidTV $version), CFNetwork")
val uri = URI.create(SOCKET_URI)
val okHttpClient = OkHttpClient.Builder().build()
IO.setDefaultOkHttpWebSocketFactory(okHttpClient)
IO.setDefaultOkHttpCallFactory(okHttpClient)
val opts = IO.Options()
opts.query = "__sails_io_sdk_version=1.2.1&__sails_io_sdk_platform=browser&__sails_io_sdk_language=javascript"
opts.transports = arrayOf(WebSocket.NAME)
opts.forceNew = true
opts.callFactory = okHttpClient
opts.webSocketFactory = okHttpClient
val uri = URI.create(SOCKET_URI)
val socket = IO.socket(uri, opts)
val opts = IO.Options()
opts.query = "__sails_io_sdk_version=1.2.1&__sails_io_sdk_platform=browser&__sails_io_sdk_language=javascript"
opts.transports = arrayOf(WebSocket.NAME)
opts.forceNew = true
opts.callFactory = okHttpClient;
opts.webSocketFactory = okHttpClient;
val socket = IO.socket(uri, opts)
// Modify *initial* request headers
socket.io().on(Manager.EVENT_TRANSPORT) { args ->
val transport: Transport = args[0] as Transport
transport.on(Transport.EVENT_REQUEST_HEADERS) {
// Request Headers
val headers = it[0] as MutableMap<String, List<String>>
// Modify Request Headers
headers["Origin"] = listOf("https://www.floatplane.com")
headers["Cookie"] = listOf(getCookiesString())
headers["User-Agent"] = listOf("Hydravion (AndroidTV $version), CFNetwork")
MainFragment.dLog("$TAG --> MODIFYING HEADERS", headers.toString())
// Modify *initial* request headers
socket.io().on(Manager.EVENT_TRANSPORT) { args ->
val transport: Transport = args[0] as Transport
transport.on(Transport.EVENT_REQUEST_HEADERS) {
// Request Headers
val headers = it[0] as MutableMap<String, List<String>>
// Always use the latest access token
val currentToken = authManager.getAccessToken()
headers["Origin"] = listOf("https://www.floatplane.com")
headers["Authorization"] = listOf("Bearer $currentToken")
headers["User-Agent"] = listOf("Hydravion (AndroidTV $version)")
MainFragment.dLog("$TAG --> MODIFYING HEADERS", headers.toString())
}
transport.on(Transport.EVENT_RESPONSE_HEADERS){
// Response Headers
val headers = it[0] as Map<String, List<String>>
MainFragment.dLog("$TAG --> RESPONSE HEADERS", headers.toString())
}
}
transport.on(Transport.EVENT_RESPONSE_HEADERS){
// Response Headers
val headers = it[0] as Map<String, List<String>>
MainFragment.dLog("$TAG --> RESPONSE HEADERS", headers.toString())
}
}
socket.connect()
return socket
socket.connect()
onReady(socket)
}, {
MainFragment.dLog(TAG, "Failed to obtain access token for socket")
onReady(null)
})
}
// Methods to parse UserSync and SyncEvents

View File

@@ -75,7 +75,7 @@ public class VideoDetailsFragment extends DetailsSupportFragment {
private DetailsSupportFragmentBackgroundController mDetailsBackground;
private static final String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
private static final String userAgent = String.format("Hydravion %s (AndroidTV), CFNetwork", version);
private static final String userAgent = String.format("Hydravion %s (AndroidTV)", version);
@Override
public void onCreate(Bundle savedInstanceState) {

View File

@@ -33,6 +33,7 @@ import java.util.HashMap;
import kotlin.Unit;
import ml.bmlzootown.hydravion.R;
import ml.bmlzootown.hydravion.authenticate.AuthManager;
import ml.bmlzootown.hydravion.browse.MainFragment;
import ml.bmlzootown.hydravion.client.HydravionClient;
import ml.bmlzootown.hydravion.detail.DetailsActivity;
@@ -57,6 +58,8 @@ public class PlaybackActivity extends FragmentActivity {
private int currentWindow = 0;
private long playbackPosition = 0;
private boolean resumed = false;
private boolean playerInitialized = false;
private boolean initializationInProgress = false;
private String url = "";
private Video video;
@@ -101,9 +104,10 @@ public class PlaybackActivity extends FragmentActivity {
super.onStart();
if (Util.SDK_INT > 23) {
initializePlayer();
mediaController.setPlayer(player);
mediaSession.setActive(true);
// Only initialize if not already initialized or in progress
if (!playerInitialized && !initializationInProgress && player == null) {
initializePlayer();
}
}
}
@@ -112,9 +116,10 @@ public class PlaybackActivity extends FragmentActivity {
super.onResume();
if (Util.SDK_INT <= 23 || player == null) {
initializePlayer();
mediaController.setPlayer(player);
mediaSession.setActive(true);
// Only initialize if not already initialized or in progress
if (!playerInitialized && !initializationInProgress) {
initializePlayer();
}
}
}
@@ -123,7 +128,15 @@ public class PlaybackActivity extends FragmentActivity {
super.onPause();
if (Util.SDK_INT <= 23) {
mediaController.setPlayer(null);
// Cancel any in-progress initialization
if (initializationInProgress) {
initializationInProgress = false;
}
if (playerInitialized) {
mediaController.setPlayer(null);
playerInitialized = false;
}
mediaSession.setActive(false);
saveVideoPosition();
releasePlayer();
@@ -135,7 +148,15 @@ public class PlaybackActivity extends FragmentActivity {
super.onStop();
if (Util.SDK_INT > 23) {
mediaController.setPlayer(null);
// Cancel any in-progress initialization
if (initializationInProgress) {
initializationInProgress = false;
}
if (playerInitialized) {
mediaController.setPlayer(null);
playerInitialized = false;
}
mediaSession.setActive(false);
saveVideoPosition();
releasePlayer();
@@ -232,51 +253,89 @@ public class PlaybackActivity extends FragmentActivity {
}
private void initializePlayer() {
player = new ExoPlayer.Builder(this).build();
player.setPlayWhenReady(playWhenReady);
player.seekTo(currentWindow, playbackPosition);
playerView.setPlayer(player);
DefaultHttpDataSource.Factory dataSourceFactory = new DefaultHttpDataSource.Factory();
HashMap<String, String> cookieMap = new HashMap<>();
cookieMap.put("Cookie", "sails.sid=" + MainFragment.sailssid + ";");
dataSourceFactory.setDefaultRequestProperties(cookieMap);
int flags = DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES | DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS;
DefaultHlsExtractorFactory extractorFactory = new DefaultHlsExtractorFactory(flags, true);
MediaItem mi = MediaItem.fromUri(url);
HlsMediaSource hlsMediaSource = new HlsMediaSource.Factory(dataSourceFactory).setExtractorFactory(extractorFactory).createMediaSource(mi);
player.setMediaSource(hlsMediaSource);
player.prepare();
player.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
if (video != null) {
releasePlayer();
Toast.makeText(PlaybackActivity.this, "Video could not be played!", Toast.LENGTH_LONG).show();
}
MainFragment.dError("EXOPLAYER", error.getLocalizedMessage());
// Mark initialization as in progress
initializationInProgress = true;
AuthManager authManager = AuthManager.Companion.getInstance(this, getPreferences(Context.MODE_PRIVATE));
authManager.withValidAccessToken(accessToken -> {
// Check if initialization was cancelled or activity is no longer valid
if (!initializationInProgress || isFinishing() || isDestroyed()) {
initializationInProgress = false;
return Unit.INSTANCE;
}
player = new ExoPlayer.Builder(this).build();
player.setPlayWhenReady(playWhenReady);
player.seekTo(currentWindow, playbackPosition);
playerView.setPlayer(player);
@Override
public void onPlaybackStateChanged(int state) {
MainFragment.dLog("STATE", state + "");
switch (state) {
case Player.STATE_READY:
if (getIntent().getBooleanExtra(DetailsActivity.Resume, false) && !resumed) {
player.seekTo(video.getVideoInfo().getProgress() * 1000);
resumed = true;
}
break;
case Player.STATE_ENDED:
saveVideoPosition();
DefaultHttpDataSource.Factory dataSourceFactory = new DefaultHttpDataSource.Factory();
String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
HashMap<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
headers.put("User-Agent", "Hydravion (AndroidTV " + version + ")");
dataSourceFactory.setDefaultRequestProperties(headers);
int flags = DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES | DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS;
DefaultHlsExtractorFactory extractorFactory = new DefaultHlsExtractorFactory(flags, true);
MediaItem mi = MediaItem.fromUri(url);
HlsMediaSource hlsMediaSource = new HlsMediaSource.Factory(dataSourceFactory).setExtractorFactory(extractorFactory).createMediaSource(mi);
player.setMediaSource(hlsMediaSource);
player.prepare();
// Set up player listener
player.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
if (video != null) {
releasePlayer();
break;
default:
break;
Toast.makeText(PlaybackActivity.this, "Video could not be played!", Toast.LENGTH_LONG).show();
}
MainFragment.dError("EXOPLAYER", error.getLocalizedMessage());
}
@Override
public void onPlaybackStateChanged(int state) {
MainFragment.dLog("STATE", state + "");
switch (state) {
case Player.STATE_READY:
if (getIntent().getBooleanExtra(DetailsActivity.Resume, false) && !resumed) {
player.seekTo(video.getVideoInfo().getProgress() * 1000);
resumed = true;
}
break;
case Player.STATE_ENDED:
saveVideoPosition();
releasePlayer();
break;
default:
break;
}
}
});
// Only set up media session if activity is still in a valid state
if (!isFinishing() && !isDestroyed()) {
mediaController.setPlayer(player);
mediaSession.setActive(true);
playerInitialized = true;
} else {
// Activity is no longer valid, release the player
if (player != null) {
player.release();
player = null;
}
}
initializationInProgress = false;
return Unit.INSTANCE;
}, () -> {
initializationInProgress = false;
Toast.makeText(this, "Session expired. Please relink your account.", Toast.LENGTH_LONG).show();
finish();
return Unit.INSTANCE;
});
}
@@ -294,6 +353,8 @@ public class PlaybackActivity extends FragmentActivity {
player.stop();
player.release();
player = null;
playerInitialized = false;
initializationInProgress = false;
this.finish();
}
}

View File

@@ -42,7 +42,7 @@ class SubscriptionHeaderPresenter : RowHeaderPresenter() {
.load(
GlideUrl(
creator.icon?.path, LazyHeaders.Builder()
.addHeader("User-Agent", "Hydravion (AndroidTV $version), CFNetwork")
.addHeader("User-Agent", "Hydravion (AndroidTV $version)")
.build()
)
)

View File

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

View File

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

View File

@@ -0,0 +1,128 @@
<?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:background="#1a1a1a"
android:padding="48dp">
<TextView
android:id="@+id/title_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Login"
android:textColor="@android:color/white"
android:textSize="36sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Left Column: Instructions -->
<ScrollView
android:id="@+id/instructions_scroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="48dp"
android:layout_marginEnd="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/right_column"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title_text">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/instructions_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scan the QR code or visit floatplane.com/link on another device to log in to your Floatplane account."
android:textColor="@android:color/white"
android:textSize="18sp"
android:lineSpacingMultiplier="1.2"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="How to link your account:"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/cookie_steps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="1. Using a browser, go to floatplane.com/link\n2. When prompted, enter the code shown on this screen\n3. Once complete, this app will sign in automatically"
android:textColor="@android:color/white"
android:textSize="16sp"
android:lineSpacingMultiplier="1.3"
android:layout_marginBottom="24dp" />
<TextView
android:id="@+id/user_code_display"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="@android:color/white"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:gravity="center"
android:background="#333333"
android:padding="20dp"
android:layout_marginTop="16dp" />
<TextView
android:id="@+id/status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Requesting login code..."
android:textColor="@android:color/white"
android:textSize="16sp"
android:gravity="center"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>
<!-- Right Column: Server Status, QR Code, URL -->
<LinearLayout
android:id="@+id/right_column"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="48dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/instructions_scroll"
app:layout_constraintTop_toBottomOf="@+id/title_text">
<ImageView
android:id="@+id/qr_code"
android:layout_width="280dp"
android:layout_height="280dp"
android:scaleType="fitCenter"
android:layout_gravity="center_horizontal"
android:adjustViewBounds="true" />
<TextView
android:id="@+id/timer_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Time Remaining: 10:00"
android:textColor="@android:color/white"
android:textSize="18sp"
android:gravity="center"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.1'
classpath 'com.android.tools.build:gradle:8.13.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -1,6 +1,6 @@
#Sat Apr 17 08:39:50 CDT 2021
#Sat Nov 08 18:41:15 EST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip