diff --git a/extensions/shared/src/main/java/app/morphe/extension/shared/ui/SheetBottomDialog.java b/extensions/shared/src/main/java/app/morphe/extension/shared/ui/SheetBottomDialog.java index 6008d540f..e06b8d7f5 100644 --- a/extensions/shared/src/main/java/app/morphe/extension/shared/ui/SheetBottomDialog.java +++ b/extensions/shared/src/main/java/app/morphe/extension/shared/ui/SheetBottomDialog.java @@ -10,6 +10,7 @@ import android.app.Dialog; import android.content.Context; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.RoundRectShape; +import android.graphics.Rect; import android.view.Gravity; import android.view.MotionEvent; import android.view.VelocityTracker; @@ -58,21 +59,13 @@ public class SheetBottomDialog { // Create wrapper layout for side margins. LinearLayout wrapperLayout = new LinearLayout(context); wrapperLayout.setOrientation(LinearLayout.VERTICAL); + wrapperLayout.setGravity(Gravity.BOTTOM); // Create drag container. DraggableLinearLayout dragContainer = new DraggableLinearLayout(context, animationDuration); dragContainer.setOrientation(LinearLayout.VERTICAL); dragContainer.setDialog(dialog); - // Add top spacer. - View spacer = new View(context); - final int dip40 = dipToPixels(40); - LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, dip40); - spacer.setLayoutParams(spacerParams); - spacer.setClickable(true); - dragContainer.addView(spacer); - // Add content view. ViewGroup parent = (ViewGroup) contentView.getParent(); if (parent != null) parent.removeView(contentView); @@ -173,6 +166,8 @@ public class SheetBottomDialog { private SlideDialog dialog; private float dismissThreshold; + @Nullable + private View touchedScrollableChild; /** * Constructs a new {@link DraggableLinearLayout} with the specified context. @@ -223,23 +218,26 @@ public class SheetBottomDialog { */ @Override public boolean onInterceptTouchEvent(MotionEvent ev) { - if (!isDragEnabled) return false; + if (dialog == null || !isDragEnabled) return false; switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: initialTouchRawY = ev.getRawY(); + float initialTouchX = ev.getX(); + float initialTouchY = ev.getY(); isDragging = false; scroller.forceFinished(true); removeCallbacks(settleRunnable); velocityTracker.clear(); velocityTracker.addMovement(ev); dragOffset = getTranslationY(); + touchedScrollableChild = findScrollableChildUnder(this, (int) initialTouchX, (int) initialTouchY); break; case MotionEvent.ACTION_MOVE: float dy = ev.getRawY() - initialTouchRawY; if (dy > ViewConfiguration.get(getContext()).getScaledTouchSlop() - && !canChildScrollUp()) { + && canStartDrag()) { isDragging = true; return true; // Intercept touches for drag. } @@ -255,7 +253,7 @@ public class SheetBottomDialog { @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent ev) { - if (!isDragEnabled) return super.onTouchEvent(ev); + if (dialog == null || !isDragEnabled) return super.onTouchEvent(ev); velocityTracker.addMovement(ev); switch (ev.getActionMasked()) { @@ -327,28 +325,45 @@ public class SheetBottomDialog { * * @return True if a child can scroll upward, false otherwise. */ - private boolean canChildScrollUp() { - View target = findScrollableChild(this); - return target != null && target.canScrollVertically(-1); + private boolean canStartDrag() { + return touchedScrollableChild == null || !touchedScrollableChild.canScrollVertically(-1); } /** - * Recursively searches for a scrollable child view within the given view group. + * Recursively searches for a scrollable child under the given touch point. * * @param group The view group to search. - * @return The scrollable child view, or null if none found. + * @param x The X coordinate relative to the current group. + * @param y The Y coordinate relative to the current group. + * @return The scrollable child view under the touch point, or null if none found. */ - private View findScrollableChild(ViewGroup group) { - for (int i = 0; i < group.getChildCount(); i++) { + @Nullable + private View findScrollableChildUnder(@NonNull ViewGroup group, int x, int y) { + for (int i = group.getChildCount() - 1; i >= 0; i--) { View child = group.getChildAt(i); - if (child.canScrollVertically(-1)) return child; + if (child.getVisibility() != View.VISIBLE || !isPointInsideChild(child, x, y)) { + continue; + } + + int childX = x - child.getLeft() + child.getScrollX(); + int childY = y - child.getTop() + child.getScrollY(); + if (child instanceof ViewGroup) { - View scroll = findScrollableChild((ViewGroup) child); + View scroll = findScrollableChildUnder((ViewGroup) child, childX, childY); if (scroll != null) return scroll; } + + if (child.canScrollVertically(-1) || child.canScrollVertically(1)) { + return child; + } } return null; } + + private boolean isPointInsideChild(@NonNull View child, int x, int y) { + Rect bounds = new Rect(child.getLeft(), child.getTop(), child.getRight(), child.getBottom()); + return bounds.contains(x, y); + } } /** diff --git a/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiBottomSheetUi.java b/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiBottomSheetUi.java new file mode 100644 index 000000000..95150451a --- /dev/null +++ b/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiBottomSheetUi.java @@ -0,0 +1,1278 @@ +/* + * Copyright (C) 2026 anddea + * + * This file is part of the revanced-patches project: + * https://github.com/anddea/revanced-patches + * + * Original author(s): + * - anddea (https://github.com/anddea) + * + * Licensed under the GNU General Public License v3.0. + * + * ------------------------------------------------------------------------ + * GPLv3 Section 7 – Attribution Notice + * ------------------------------------------------------------------------ + * + * This file contains substantial original work by the author(s) listed above. + * + * In accordance with Section 7 of the GNU General Public License v3.0, + * the following additional terms apply to this file: + * + * 1. Attribution (Section 7(b)): This specific copyright notice and the + * list of original authors above must be preserved in any copy or + * derivative work. You may add your own copyright notice below it, + * but you may not remove the original one. + * + * 2. Origin (Section 7(c)): Modified versions must be clearly marked as + * such (e.g., by adding a "Modified by" line or a new copyright notice). + * They must not be misrepresented as the original work. + * + * ------------------------------------------------------------------------ + * Version Control Acknowledgement (Non-binding Request) + * ------------------------------------------------------------------------ + * + * While not a legal requirement of the GPLv3, the original author(s) + * respectfully request that ports or substantial modifications retain + * historical authorship credit in version control systems (e.g., Git), + * listing original author(s) appropriately and modifiers as committers + * or co-authors. + */ + +package app.morphe.extension.youtube.utils; + +import static app.morphe.extension.shared.utils.ResourceUtils.getDrawable; +import static app.morphe.extension.shared.utils.StringRef.str; +import static app.morphe.extension.shared.utils.Utils.dipToPixels; + +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.os.Build; +import android.text.InputType; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.animation.LinearInterpolator; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import app.morphe.extension.shared.ui.SheetBottomDialog; +import app.morphe.extension.shared.utils.Utils; + +final class GeminiBottomSheetUi { + private static final int SECONDARY_BUTTON_BACKGROUND_COLOR = Color.parseColor("#33888888"); + private static final float SHEET_HEIGHT_FRACTION = 0.9f; + private static final int MIN_SHEET_HEIGHT_DP = 240; + private GeminiBottomSheetUi() { + } + + interface OnSendListener { + void onSendRequested(@NonNull SummarySheet sheet, @NonNull String question); + } + + interface OnCopyListener { + void onCopyRequested(@NonNull String text); + } + + interface MessageFormatter { + @NonNull + CharSequence format(@NonNull String text); + } + + interface OnConversationChangedListener { + void onConversationChanged(@NonNull List conversationHistory); + } + + static final class LoadingSheet { + private final SheetBottomDialog.SlideDialog dialog; + private final TextView metaView; + private final ShimmerTextView statusView; + private final TextView elapsedView; + private final View previewContainer; + private final TextView previewView; + private final MaxHeightScrollView previewScrollView; + private boolean dismissedByOwner; + private boolean followPreviewBottom; + + LoadingSheet( + @NonNull Context context, + @NonNull String title, + @Nullable String metaText, + @NonNull String statusText, + @NonNull Runnable onCancel, + @NonNull Runnable onUserDismiss + ) { + final int dip8 = dipToPixels(8); + final int dip12 = dipToPixels(12); + final int dip16 = dipToPixels(16); + final int dip20 = dipToPixels(20); + + SheetBottomDialog.DraggableLinearLayout mainLayout = + SheetBottomDialog.createMainLayout(context, ThemeUtils.getDialogBackgroundColor()); + mainLayout.setPadding(dip20, 0, dip20, dip20); + + TextView titleView = createTitleView(context, title); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + titleParams.topMargin = dip20; + mainLayout.addView(titleView, titleParams); + + metaView = createMetaView(context); + mainLayout.addView(metaView); + setTextOrHide(metaView, metaText); + + statusView = new ShimmerTextView(context); + statusView.setText(statusText); + statusView.setTextColor(ThemeUtils.getAppForegroundColor()); + statusView.setTextSize(16); + statusView.setGravity(Gravity.CENTER_HORIZONTAL); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + statusParams.topMargin = dip12; + mainLayout.addView(statusView, statusParams); + + elapsedView = new TextView(context); + elapsedView.setTextColor(ThemeUtils.getAppForegroundColor()); + elapsedView.setTextSize(13); + elapsedView.setGravity(Gravity.CENTER_HORIZONTAL); + LinearLayout.LayoutParams elapsedParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + elapsedParams.topMargin = dip8; + mainLayout.addView(elapsedView, elapsedParams); + + previewScrollView = new MaxHeightScrollView(context); + previewScrollView.setMaxHeight((int) (context.getResources().getDisplayMetrics().heightPixels * 0.34f)); + previewScrollView.setVerticalScrollBarEnabled(false); + previewScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); + previewScrollView.setFillViewport(true); + previewScrollView.setOnScrollPositionChangedListener( + () -> followPreviewBottom = previewScrollView.isNearBottom(dipToPixels(28)) + ); + LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + previewParams.topMargin = dip16; + + previewView = new TextView(context); + previewView.setTextColor(ThemeUtils.getAppForegroundColor()); + previewView.setTextSize(15); + previewView.setTextIsSelectable(false); + previewView.setPadding(dip16, dip16, dip16, dip16); + previewView.setBackground(createRoundedBackground( + ThemeUtils.getAdjustedBackgroundColor(false), + 18 + )); + previewScrollView.addView(previewView, new ScrollView.LayoutParams( + ScrollView.LayoutParams.MATCH_PARENT, + ScrollView.LayoutParams.WRAP_CONTENT + )); + previewScrollView.setVisibility(View.GONE); + mainLayout.addView(previewScrollView, previewParams); + previewContainer = previewScrollView; + followPreviewBottom = false; + + LinearLayout buttonsRow = createButtonsRow(context); + Button cancelButton = createActionButton(context, str("revanced_cancel"), true); + cancelButton.setOnClickListener(v -> { + dismissedByOwner = true; + onCancel.run(); + }); + addWeightedButton(buttonsRow, cancelButton); + mainLayout.addView(buttonsRow); + + dialog = SheetBottomDialog.createSlideDialog(context, mainLayout, 180); + dialog.setOnDismissListener(d -> { + statusView.stopShimmer(); + if (!dismissedByOwner) { + onUserDismiss.run(); + } + }); + + Window window = dialog.getWindow(); + if (window != null) { + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); + } + statusView.startShimmer(); + dialog.show(); + } + + boolean isShowing() { + return dialog.isShowing(); + } + + void dismiss() { + dismissedByOwner = true; + statusView.stopShimmer(); + dialog.dismiss(); + } + + void updateMeta(@Nullable String metaText) { + setTextOrHide(metaView, metaText); + } + + void updateStatus(@NonNull String statusText) { + statusView.setText(statusText); + } + + void updateElapsed(int seconds) { + elapsedView.setText(seconds >= 0 ? seconds + "s" : ""); + } + + void updatePreview(@NonNull String previewText) { + previewContainer.setVisibility(View.VISIBLE); + previewView.setVisibility(View.VISIBLE); + previewView.setText(previewText); + + if (followPreviewBottom) { + previewScrollView.post(() -> previewScrollView.fullScroll(View.FOCUS_DOWN)); + } + } + } + + static final class SummarySheet { + private final Context context; + private final SheetBottomDialog.SlideDialog dialog; + private final LinearLayout messagesContainer; + private final MaxHeightScrollView messagesScrollView; + private final LinearLayout footerContainer; + private final EditText inputView; + private final ImageButton sendButton; + private final MessageFormatter assistantMessageFormatter; + private final OnCopyListener onCopyListener; + private final OnConversationChangedListener onConversationChangedListener; + private final List conversationHistory = new ArrayList<>(); + private final int contentHorizontalPadding; + private final int footerVerticalPadding; + @Nullable + private String pendingUserQuestion; + @Nullable + private MessageEntry pendingAssistantEntry; + @Nullable + private ViewTreeObserver.OnGlobalLayoutListener keyboardLayoutListener; + @Nullable + private View keyboardObserverView; + private boolean dismissedByOwner; + private boolean followConversationBottom; + private int lastKeyboardInset; + + SummarySheet( + @NonNull Context context, + @Nullable String metaText, + @Nullable String infoText, + @NonNull CharSequence summaryText, + @NonNull String rawSummaryText, + @NonNull List initialConversationHistory, + @NonNull MessageFormatter assistantMessageFormatter, + @NonNull OnCopyListener onCopyListener, + @NonNull OnConversationChangedListener onConversationChangedListener, + @NonNull Runnable onUserDismiss, + @NonNull OnSendListener onSendListener + ) { + this.context = context; + this.assistantMessageFormatter = assistantMessageFormatter; + this.onCopyListener = onCopyListener; + this.onConversationChangedListener = onConversationChangedListener; + + final int dip8 = dipToPixels(8); + final int dip10 = dipToPixels(10); + final int dip16 = dipToPixels(16); + final int dip20 = dipToPixels(20); + final int dip42 = dipToPixels(42); + + SheetBottomDialog.DraggableLinearLayout mainLayout = + SheetBottomDialog.createMainLayout(context, ThemeUtils.getDialogBackgroundColor()); + contentHorizontalPadding = dip16; + footerVerticalPadding = dip8; + mainLayout.setPadding(contentHorizontalPadding, 0, contentHorizontalPadding, 0); + + LinearLayout headerContainer = new LinearLayout(context); + headerContainer.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams headerParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + headerParams.topMargin = dip20; + mainLayout.addView(headerContainer, headerParams); + + TextView titleView = createTitleView(context, str("revanced_gemini_summary_title")); + headerContainer.addView(titleView); + + TextView metaView = createMetaView(context); + headerContainer.addView(metaView); + setTextOrHide(metaView, metaText); + + TextView infoView = createSecondaryTextView(context); + headerContainer.addView(infoView); + setTextOrHide(infoView, infoText); + + messagesScrollView = new MaxHeightScrollView(context); + messagesScrollView.setVerticalScrollBarEnabled(false); + messagesScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); + messagesScrollView.setFillViewport(true); + messagesScrollView.setOnScrollPositionChangedListener( + () -> followConversationBottom = messagesScrollView.isNearBottom(dipToPixels(32)) + ); + LinearLayout.LayoutParams messagesParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f + ); + messagesParams.topMargin = dip16; + + messagesContainer = new LinearLayout(context); + messagesContainer.setOrientation(LinearLayout.VERTICAL); + messagesScrollView.addView(messagesContainer, new ScrollView.LayoutParams( + ScrollView.LayoutParams.MATCH_PARENT, + ScrollView.LayoutParams.WRAP_CONTENT + )); + mainLayout.addView(messagesScrollView, messagesParams); + followConversationBottom = false; + + appendSummaryMessage(summaryText, rawSummaryText); + + for (GeminiUtils.ChatMessage message : initialConversationHistory) { + conversationHistory.add(message); + if ("user".equals(message.role())) { + appendUserMessage(message.text(), false); + } else { + appendAssistantMessage(message.text()); + } + } + + footerContainer = new LinearLayout(context); + footerContainer.setOrientation(LinearLayout.VERTICAL); + footerContainer.setPadding(0, footerVerticalPadding, 0, footerVerticalPadding); + mainLayout.addView(footerContainer, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + LinearLayout inputRow = new LinearLayout(context); + inputRow.setOrientation(LinearLayout.HORIZONTAL); + inputRow.setGravity(Gravity.BOTTOM); + footerContainer.addView(inputRow, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + inputView = new EditText(context); + inputView.setHint(str("revanced_gemini_chat_input_hint")); + inputView.setTextColor(ThemeUtils.getAppForegroundColor()); + inputView.setHintTextColor(withAlpha(ThemeUtils.getAppForegroundColor(), 150)); + inputView.setBackground(createRoundedBackground( + ThemeUtils.getAdjustedBackgroundColor(false), + 20 + )); + inputView.setFocusableInTouchMode(true); + inputView.setSingleLine(true); + inputView.setMinLines(1); + inputView.setMaxLines(1); + inputView.setTextSize(15); + inputView.setMinHeight(dip42); + inputView.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); + inputView.setInputType(InputType.TYPE_CLASS_TEXT + | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); + inputView.setPadding(dip16, dip10, dip16, dip10); + inputView.setOverScrollMode(View.OVER_SCROLL_NEVER); + inputView.setVerticalScrollBarEnabled(false); + LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + inputRow.addView(inputView, inputParams); + + sendButton = createIconButton( + context, + "revanced_gemini_send", + true, + str("revanced_send") + ); + LinearLayout.LayoutParams sendParams = new LinearLayout.LayoutParams( + dip42, + dip42 + ); + sendParams.setMarginStart(dip8); + inputRow.addView(sendButton, sendParams); + + inputView.setOnClickListener(v -> { + inputView.requestFocus(); + followConversationBottom = true; + forceScrollToBottom(); + }); + inputView.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + followConversationBottom = true; + forceScrollToBottom(); + } + }); + + sendButton.setOnClickListener(v -> { + if (pendingUserQuestion != null) { + return; + } + + String question = inputView.getText().toString().trim(); + if (TextUtils.isEmpty(question)) { + return; + } + + inputView.setText(""); + beginPendingResponse(question); + onSendListener.onSendRequested(this, question); + }); + + dialog = SheetBottomDialog.createSlideDialog(context, mainLayout, 180); + dialog.setOnDismissListener(d -> { + clearKeyboardInsetsListener(); + if (!dismissedByOwner) { + onUserDismiss.run(); + } + }); + + Window window = dialog.getWindow(); + if (window != null) { + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + | WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); + } + + dialog.show(); + applySheetFrame(mainLayout, 0); + bindKeyboardInsets(mainLayout); + + if (conversationHistory.isEmpty()) { + scrollToTop(); + } else { + forceScrollToBottom(); + } + } + + boolean isShowing() { + return dialog.isShowing(); + } + + void dismiss() { + dismissedByOwner = true; + clearKeyboardInsetsListener(); + dialog.dismiss(); + } + + void beginPendingResponse(@NonNull String question) { + pendingUserQuestion = question; + appendUserMessage(question, true); + pendingAssistantEntry = appendAssistantMessage("", false, true, true); + pendingAssistantEntry.messageView.setText("..."); + setSending(true); + } + + @NonNull + List getConversationWithPendingQuestion() { + List snapshot = new ArrayList<>(conversationHistory); + if (!TextUtils.isEmpty(pendingUserQuestion)) { + snapshot.add(new GeminiUtils.ChatMessage("user", pendingUserQuestion)); + } + return snapshot; + } + + void updatePendingResponse(@NonNull String text) { + if (pendingAssistantEntry == null) { + return; + } + + pendingAssistantEntry.rawText = text; + if (TextUtils.isEmpty(text)) { + pendingAssistantEntry.messageView.setText("..."); + } else { + stopShimmerIfNeeded(pendingAssistantEntry.messageView); + setMessageText(pendingAssistantEntry.messageView, assistantMessageFormatter.format(text)); + } + maybeScrollToBottom(false); + } + + void commitPendingResponse(@NonNull String text) { + if (!TextUtils.isEmpty(pendingUserQuestion)) { + conversationHistory.add(new GeminiUtils.ChatMessage("user", pendingUserQuestion)); + conversationHistory.add(new GeminiUtils.ChatMessage("model", text)); + onConversationChangedListener.onConversationChanged(new ArrayList<>(conversationHistory)); + } + + if (pendingAssistantEntry != null) { + pendingAssistantEntry.rawText = text; + stopShimmerIfNeeded(pendingAssistantEntry.messageView); + setMessageText(pendingAssistantEntry.messageView, assistantMessageFormatter.format(text)); + if (pendingAssistantEntry.copyButton != null) { + pendingAssistantEntry.copyButton.setVisibility(View.VISIBLE); + pendingAssistantEntry.copyButton.setEnabled(true); + } + } + + pendingUserQuestion = null; + pendingAssistantEntry = null; + setSending(false); + maybeScrollToBottom(false); + } + + void failPendingResponse() { + if (pendingAssistantEntry != null) { + stopShimmerIfNeeded(pendingAssistantEntry.messageView); + } + if (pendingAssistantEntry != null && pendingAssistantEntry.row.getParent() instanceof ViewGroup parent) { + parent.removeView(pendingAssistantEntry.row); + } + pendingUserQuestion = null; + pendingAssistantEntry = null; + setSending(false); + maybeScrollToBottom(false); + } + + private void setSending(boolean sending) { + sendButton.setEnabled(!sending); + inputView.setEnabled(!sending); + sendButton.setAlpha(sending ? 0.55f : 1f); + inputView.setAlpha(sending ? 0.7f : 1f); + } + + private void appendSummaryMessage( + @NonNull CharSequence text, + @NonNull String rawText + ) { + final int dip6 = dipToPixels(6); + final int dip8 = dipToPixels(8); + final int dip36 = dipToPixels(36); + + LinearLayout section = new LinearLayout(context); + section.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams sectionParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + sectionParams.topMargin = dip6; + section.setLayoutParams(sectionParams); + + TextView messageView = createMessageView(false); + messageView.setMaxWidth(Integer.MAX_VALUE); + setMessageText(messageView, text); + section.addView(messageView, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + ImageButton copyButton = createIconButton( + context, + "revanced_gemini_copy", + false, + str("revanced_copy") + ); + copyButton.setOnClickListener(v -> onCopyListener.onCopyRequested(rawText)); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams(dip36, dip36); + copyParams.topMargin = dip8; + copyParams.gravity = Gravity.END; + section.addView(copyButton, copyParams); + + messagesContainer.addView(section); + maybeScrollToBottom(false); + } + + private void appendUserMessage(@NonNull String rawText, boolean forceScroll) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.END); + LinearLayout.LayoutParams rowParams = createMessageRowLayoutParams(); + row.setLayoutParams(rowParams); + + TextView messageView = createMessageView(true); + setMessageText(messageView, rawText); + row.addView(messageView); + + messagesContainer.addView(row); + maybeScrollToBottom(forceScroll); + } + + @NonNull + private MessageEntry appendAssistantMessage( + @NonNull String rawText, + boolean showCopyButton, + boolean forceScroll, + boolean shimmerWhilePending + ) { + final int dip8 = dipToPixels(8); + final int dip36 = dipToPixels(36); + + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.START | Gravity.BOTTOM); + LinearLayout.LayoutParams rowParams = createMessageRowLayoutParams(); + row.setLayoutParams(rowParams); + + TextView messageView = createMessageView(false, shimmerWhilePending); + if (TextUtils.isEmpty(rawText)) { + messageView.setText(""); + } else { + setMessageText(messageView, assistantMessageFormatter.format(rawText)); + } + row.addView(messageView); + + ImageButton copyButton = createIconButton( + context, + "revanced_gemini_copy", + false, + str("revanced_copy") + ); + copyButton.setVisibility(showCopyButton ? View.VISIBLE : View.GONE); + copyButton.setEnabled(showCopyButton); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams(dip36, dip36); + copyParams.leftMargin = dip8; + row.addView(copyButton, copyParams); + + MessageEntry entry = new MessageEntry(row, messageView, copyButton, rawText); + copyButton.setOnClickListener(v -> { + if (!TextUtils.isEmpty(entry.rawText)) { + onCopyListener.onCopyRequested(entry.rawText); + } + }); + + messagesContainer.addView(row); + maybeScrollToBottom(forceScroll); + return entry; + } + + private void appendAssistantMessage( + @NonNull String rawText + ) { + appendAssistantMessage(rawText, true, false, false); + } + + @NonNull + private LinearLayout.LayoutParams createMessageRowLayoutParams() { + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + rowParams.topMargin = dipToPixels(6); + return rowParams; + } + + @NonNull + private TextView createMessageView(boolean isUser, boolean shimmerWhilePending) { + final int dip12 = dipToPixels(12); + final int maxWidth = (int) (context.getResources().getDisplayMetrics().widthPixels * (isUser ? 0.74f : 0.68f)); + + TextView messageView = shimmerWhilePending ? new ShimmerTextView(context) : new TextView(context); + messageView.setMaxWidth(maxWidth); + messageView.setTextSize(15); + messageView.setTextColor(isUser + ? (ThemeUtils.isDarkModeEnabled() ? Color.BLACK : Color.WHITE) + : ThemeUtils.getAppForegroundColor()); + messageView.setBackground(createRoundedBackground( + isUser + ? getPrimaryButtonBackgroundColor() + : ThemeUtils.getAdjustedBackgroundColor(false), + 18 + )); + messageView.setPadding(dip12, dip12, dip12, dip12); + if (shimmerWhilePending) { + ShimmerTextView shimmerTextView = (ShimmerTextView) messageView; + shimmerTextView.startShimmer(); + } + return messageView; + } + + @NonNull + private TextView createMessageView(boolean isUser) { + return createMessageView(isUser, false); + } + + private void maybeScrollToBottom(boolean forceScroll) { + if (forceScroll || followConversationBottom) { + forceScrollToBottom(); + } + } + + private void forceScrollToBottom() { + followConversationBottom = true; + messagesScrollView.post(() -> messagesScrollView.fullScroll(View.FOCUS_DOWN)); + } + + private void scrollToTop() { + followConversationBottom = false; + messagesScrollView.post(() -> messagesScrollView.scrollTo(0, 0)); + } + + private void bindKeyboardInsets(@NonNull View targetView) { + Window window = dialog.getWindow(); + if (window == null) { + return; + } + + keyboardObserverView = window.getDecorView(); + + keyboardLayoutListener = () -> { + View observerView = keyboardObserverView; + if (observerView == null) { + return; + } + + int keyboardInset = resolveKeyboardInset(observerView); + if (keyboardInset == lastKeyboardInset) { + return; + } + + lastKeyboardInset = keyboardInset; + applySheetFrame(targetView, keyboardInset); + + if (keyboardInset > 0) { + inputView.requestFocus(); + forceScrollToBottom(); + Rect focusRect = new Rect(0, 0, inputView.getWidth(), inputView.getHeight()); + inputView.requestRectangleOnScreen(focusRect, true); + } + }; + keyboardObserverView.getViewTreeObserver().addOnGlobalLayoutListener(keyboardLayoutListener); + } + + private void clearKeyboardInsetsListener() { + if (keyboardObserverView == null || keyboardLayoutListener == null) { + keyboardLayoutListener = null; + keyboardObserverView = null; + return; + } + + ViewTreeObserver observer = keyboardObserverView.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnGlobalLayoutListener(keyboardLayoutListener); + } + keyboardLayoutListener = null; + keyboardObserverView = null; + } + + private int resolveKeyboardInset(@NonNull View observerView) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsets insets = observerView.getRootWindowInsets(); + if (insets != null) { + return Math.max(0, insets.getInsets(WindowInsets.Type.ime()).bottom); + } + } + + Rect visibleFrame = new Rect(); + observerView.getWindowVisibleDisplayFrame(visibleFrame); + int screenHeight = observerView.getResources().getDisplayMetrics().heightPixels; + int keyboardHeight = Math.max(0, screenHeight - visibleFrame.bottom); + int keyboardThreshold = screenHeight / 6; + return keyboardHeight > keyboardThreshold ? keyboardHeight : 0; + } + + private void applySheetFrame(@NonNull View targetView, int keyboardInset) { + int screenHeight = targetView.getResources().getDisplayMetrics().heightPixels; + int bottomInset = resolveBottomInset(targetView); + int desiredHeight = (int) (screenHeight * SHEET_HEIGHT_FRACTION); + int availableHeight = Math.max( + dipToPixels(MIN_SHEET_HEIGHT_DP), + screenHeight - bottomInset + ); + int targetHeight = Math.min(desiredHeight, availableHeight); + ViewGroup.LayoutParams layoutParams = targetView.getLayoutParams(); + if (layoutParams != null) { + layoutParams.height = targetHeight; + targetView.setLayoutParams(layoutParams); + } + int keyboardLiftPadding = Math.max(0, keyboardInset); + targetView.setPadding(contentHorizontalPadding, 0, contentHorizontalPadding, 0); + footerContainer.setPadding( + 0, + footerVerticalPadding, + 0, + footerVerticalPadding + keyboardLiftPadding + ); + + Window window = dialog.getWindow(); + if (window == null) { + return; + } + + WindowManager.LayoutParams params = window.getAttributes(); + params.y = 0; + window.setAttributes(params); + } + + private int resolveBottomInset(@NonNull View targetView) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsets insets = targetView.getRootWindowInsets(); + if (insets != null) { + return Math.max(0, insets.getInsets(WindowInsets.Type.systemBars()).bottom); + } + } + + Rect visibleFrame = new Rect(); + targetView.getWindowVisibleDisplayFrame(visibleFrame); + int screenHeight = targetView.getResources().getDisplayMetrics().heightPixels; + int obscuredBottom = Math.max(0, screenHeight - visibleFrame.bottom); + int keyboardThreshold = screenHeight / 6; + return obscuredBottom > keyboardThreshold ? 0 : obscuredBottom; + } + + private void stopShimmerIfNeeded(@NonNull TextView textView) { + if (textView instanceof ShimmerTextView shimmerTextView) { + shimmerTextView.stopShimmer(); + } + } + } + + @NonNull + static SheetBottomDialog.SlideDialog showTranscriptionSheet( + @NonNull Context context, + @Nullable String metaText, + @Nullable String infoText, + @NonNull String rawTranscription, + @NonNull Runnable onCopy, + @NonNull Runnable onSubtitles + ) { + final int dip16 = dipToPixels(16); + final int dip20 = dipToPixels(20); + + SheetBottomDialog.DraggableLinearLayout mainLayout = + SheetBottomDialog.createMainLayout(context, ThemeUtils.getDialogBackgroundColor()); + mainLayout.setPadding(dip20, 0, dip20, dip20); + mainLayout.setMinimumHeight((int) (context.getResources().getDisplayMetrics().heightPixels * SHEET_HEIGHT_FRACTION)); + + TextView titleView = createTitleView(context, str("revanced_gemini_transcription_result_title")); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + titleParams.topMargin = dip20; + mainLayout.addView(titleView, titleParams); + + TextView metaView = createMetaView(context); + mainLayout.addView(metaView); + setTextOrHide(metaView, metaText); + + TextView infoView = createSecondaryTextView(context); + mainLayout.addView(infoView); + setTextOrHide(infoView, infoText); + + MaxHeightScrollView bodyScroll = new MaxHeightScrollView(context); + bodyScroll.setMaxHeight((int) (context.getResources().getDisplayMetrics().heightPixels * 0.72f)); + bodyScroll.setVerticalScrollBarEnabled(false); + bodyScroll.setOverScrollMode(View.OVER_SCROLL_NEVER); + LinearLayout.LayoutParams bodyParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f + ); + bodyParams.topMargin = dip16; + + TextView bodyView = new TextView(context); + bodyView.setText(rawTranscription); + bodyView.setTextColor(ThemeUtils.getAppForegroundColor()); + bodyView.setTextSize(15); + bodyView.setPadding(dip16, dip16, dip16, dip16); + bodyView.setBackground(createRoundedBackground( + ThemeUtils.getAdjustedBackgroundColor(false), + 18 + )); + bodyScroll.addView(bodyView, new ScrollView.LayoutParams( + ScrollView.LayoutParams.MATCH_PARENT, + ScrollView.LayoutParams.WRAP_CONTENT + )); + mainLayout.addView(bodyScroll, bodyParams); + + LinearLayout buttonsRow = createButtonsRow(context); + Button cancelButton = createActionButton(context, str("revanced_cancel"), false); + Button copyButton = createActionButton(context, str("revanced_copy"), false); + Button subtitlesButton = createActionButton(context, str("revanced_gemini_transcription_parse_button"), true); + + final SheetBottomDialog.SlideDialog dialog = SheetBottomDialog.createSlideDialog(context, mainLayout, 180); + cancelButton.setOnClickListener(v -> dialog.dismiss()); + copyButton.setOnClickListener(v -> onCopy.run()); + subtitlesButton.setOnClickListener(v -> { + onSubtitles.run(); + dialog.dismiss(); + }); + + addWeightedButton(buttonsRow, cancelButton); + addWeightedButton(buttonsRow, copyButton); + addWeightedButton(buttonsRow, subtitlesButton); + mainLayout.addView(buttonsRow); + + dialog.show(); + applyStaticSheetFrame(dialog, mainLayout); + return dialog; + } + + @NonNull + private static TextView createTitleView(@NonNull Context context, @NonNull String title) { + TextView view = new TextView(context); + view.setText(title); + view.setTextSize(18); + view.setTypeface(Typeface.DEFAULT_BOLD); + view.setGravity(Gravity.CENTER_HORIZONTAL); + view.setTextColor(ThemeUtils.getAppForegroundColor()); + return view; + } + + @NonNull + private static TextView createMetaView(@NonNull Context context) { + TextView view = new TextView(context); + view.setTextColor(ThemeUtils.getAppForegroundColor()); + view.setTextSize(14); + view.setGravity(Gravity.CENTER_HORIZONTAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dipToPixels(8); + view.setLayoutParams(params); + return view; + } + + @NonNull + private static TextView createSecondaryTextView(@NonNull Context context) { + TextView view = new TextView(context); + view.setTextColor(withAlpha(ThemeUtils.getAppForegroundColor(), 200)); + view.setTextSize(13); + view.setGravity(Gravity.CENTER_HORIZONTAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dipToPixels(4); + view.setLayoutParams(params); + return view; + } + + @NonNull + private static LinearLayout createButtonsRow(@NonNull Context context) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dipToPixels(16); + row.setLayoutParams(params); + return row; + } + + private static void addWeightedButton(@NonNull LinearLayout row, @NonNull Button button) { + final int dip4 = dipToPixels(4); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + 0, + dipToPixels(42), + 1f + ); + params.leftMargin = dip4; + params.rightMargin = dip4; + row.addView(button, params); + } + + @NonNull + private static Button createActionButton(@NonNull Context context, @NonNull String text, boolean primary) { + Button button = new Button(context, null, 0); + button.setText(text); + button.setAllCaps(false); + button.setSingleLine(true); + button.setGravity(Gravity.CENTER); + button.setTextSize(14); + button.setPadding(dipToPixels(16), 0, dipToPixels(16), 0); + button.setBackground(createRoundedBackground( + primary ? getPrimaryButtonBackgroundColor() : SECONDARY_BUTTON_BACKGROUND_COLOR, + 20 + )); + button.setTextColor(primary + ? (ThemeUtils.isDarkModeEnabled() ? Color.BLACK : Color.WHITE) + : ThemeUtils.getAppForegroundColor()); + return button; + } + + @NonNull + private static ImageButton createIconButton( + @NonNull Context context, + @NonNull String drawableName, + boolean primary, + @NonNull String contentDescription + ) { + final int iconPadding = dipToPixels(5); + + ImageButton button = new ImageButton(context); + button.setBackground(createRoundedBackground( + primary ? getPrimaryButtonBackgroundColor() : SECONDARY_BUTTON_BACKGROUND_COLOR, + 20 + )); + button.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + button.setPadding(iconPadding, iconPadding, iconPadding, iconPadding); + button.setContentDescription(contentDescription); + + Drawable drawable = getDrawable(drawableName); + if (drawable != null) { + drawable = drawable.mutate(); + drawable.setTint(primary + ? (ThemeUtils.isDarkModeEnabled() ? Color.BLACK : Color.WHITE) + : ThemeUtils.getAppForegroundColor()); + button.setImageDrawable(drawable); + } + return button; + } + + private static void setTextOrHide(@NonNull TextView textView, @Nullable String text) { + if (TextUtils.isEmpty(text)) { + textView.setVisibility(View.GONE); + } else { + textView.setVisibility(View.VISIBLE); + textView.setText(text); + } + } + + private static void setMessageText(@NonNull TextView textView, @NonNull CharSequence text) { + textView.setText(text); + if (text instanceof Spanned) { + textView.setMovementMethod(LinkMovementMethod.getInstance()); + textView.setLinksClickable(true); + } else { + textView.setMovementMethod(null); + } + } + + @NonNull + private static ShapeDrawable createRoundedBackground(int color, float radiusDp) { + ShapeDrawable background = new ShapeDrawable(new RoundRectShape( + Utils.createCornerRadii(radiusDp), null, null + )); + background.getPaint().setColor(color); + return background; + } + + private static int getPrimaryButtonBackgroundColor() { + return ThemeUtils.isDarkModeEnabled() ? Color.WHITE : Color.BLACK; + } + + private static int withAlpha(int color, int alpha) { + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } + + private static void applyStaticSheetFrame( + @NonNull SheetBottomDialog.SlideDialog dialog, + @NonNull View targetView + ) { + int screenHeight = targetView.getResources().getDisplayMetrics().heightPixels; + int bottomInset = resolveBottomInset(targetView); + int targetHeight = Math.min( + (int) (screenHeight * SHEET_HEIGHT_FRACTION), + screenHeight - bottomInset + ); + ViewGroup.LayoutParams layoutParams = targetView.getLayoutParams(); + if (layoutParams != null) { + layoutParams.height = targetHeight; + targetView.setLayoutParams(layoutParams); + } + targetView.setMinimumHeight(targetHeight); + + Window window = dialog.getWindow(); + if (window == null) { + return; + } + + WindowManager.LayoutParams params = window.getAttributes(); + params.y = 0; + window.setAttributes(params); + } + + private static int resolveBottomInset(@NonNull View targetView) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsets insets = targetView.getRootWindowInsets(); + if (insets != null) { + return Math.max(0, insets.getInsets(WindowInsets.Type.systemBars()).bottom); + } + } + + Rect visibleFrame = new Rect(); + targetView.getWindowVisibleDisplayFrame(visibleFrame); + int screenHeight = targetView.getResources().getDisplayMetrics().heightPixels; + int obscuredBottom = Math.max(0, screenHeight - visibleFrame.bottom); + int keyboardThreshold = screenHeight / 6; + return obscuredBottom > keyboardThreshold ? 0 : obscuredBottom; + } + + private static final class MessageEntry { + private final LinearLayout row; + private final TextView messageView; + @Nullable + private final ImageButton copyButton; + @NonNull + private String rawText; + + private MessageEntry( + @NonNull LinearLayout row, + @NonNull TextView messageView, + @Nullable ImageButton copyButton, + @NonNull String rawText + ) { + this.row = row; + this.messageView = messageView; + this.copyButton = copyButton; + this.rawText = rawText; + } + } + + private static final class MaxHeightScrollView extends ScrollView { + interface OnScrollPositionChangedListener { + void onScrollPositionChanged(); + } + + private int maxHeight = Integer.MAX_VALUE; + @Nullable + private OnScrollPositionChangedListener scrollPositionChangedListener; + + private MaxHeightScrollView(@NonNull Context context) { + super(context); + } + + void setMaxHeight(int maxHeight) { + this.maxHeight = maxHeight; + } + + void setOnScrollPositionChangedListener(@Nullable OnScrollPositionChangedListener listener) { + scrollPositionChangedListener = listener; + } + + boolean isNearBottom(int thresholdPx) { + View child = getChildCount() > 0 ? getChildAt(0) : null; + if (child == null) { + return true; + } + + int distanceToBottom = child.getBottom() - (getScrollY() + getHeight()); + return distanceToBottom <= thresholdPx; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int cappedHeightSpec = heightMeasureSpec; + if (maxHeight < Integer.MAX_VALUE) { + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + if (heightMode == MeasureSpec.UNSPECIFIED) { + cappedHeightSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST); + } else { + cappedHeightSpec = MeasureSpec.makeMeasureSpec( + Math.min(heightSize, maxHeight), + heightMode + ); + } + } + super.onMeasure(widthMeasureSpec, cappedHeightSpec); + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (scrollPositionChangedListener != null) { + scrollPositionChangedListener.onScrollPositionChanged(); + } + } + } + + @SuppressLint("AppCompatCustomView") + private static final class ShimmerTextView extends TextView { + @Nullable + private ValueAnimator shimmerAnimator; + @Nullable + private LinearGradient shimmerGradient; + private final Matrix shimmerMatrix = new Matrix(); + + private ShimmerTextView(@NonNull Context context) { + super(context); + } + + void startShimmer() { + if (shimmerAnimator != null) { + return; + } + shimmerAnimator = ValueAnimator.ofFloat(-1f, 2f); + shimmerAnimator.setDuration(1300L); + shimmerAnimator.setInterpolator(new LinearInterpolator()); + shimmerAnimator.setRepeatCount(ValueAnimator.INFINITE); + shimmerAnimator.addUpdateListener(animation -> { + if (shimmerGradient == null || getWidth() <= 0) { + return; + } + float animatedFraction = (float) animation.getAnimatedValue(); + shimmerMatrix.setTranslate(getWidth() * animatedFraction, 0f); + shimmerGradient.setLocalMatrix(shimmerMatrix); + invalidate(); + }); + rebuildShader(); + shimmerAnimator.start(); + } + + void stopShimmer() { + if (shimmerAnimator != null) { + shimmerAnimator.cancel(); + shimmerAnimator = null; + } + getPaint().setShader(null); + invalidate(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + rebuildShader(); + } + + private void rebuildShader() { + if (getWidth() <= 0) { + return; + } + + int baseColor = getCurrentTextColor(); + int dimColor = withAlpha(baseColor, 105); + int brightColor = withAlpha(baseColor, 255); + shimmerGradient = new LinearGradient( + -getWidth(), + 0f, + 0f, + 0f, + new int[]{dimColor, brightColor, dimColor}, + new float[]{0f, 0.5f, 1f}, + Shader.TileMode.CLAMP + ); + getPaint().setShader(shimmerGradient); + invalidate(); + } + } +} diff --git a/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiManager.java b/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiManager.java index f95a5d1f9..6ec12c39b 100644 --- a/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiManager.java +++ b/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiManager.java @@ -41,7 +41,6 @@ package app.morphe.extension.youtube.utils; import android.app.Activity; -import android.app.AlertDialog; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -51,19 +50,15 @@ import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextPaint; import android.text.TextUtils; -import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.util.Pair; import android.view.View; -import android.view.Window; import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.ScrollView; -import android.widget.TextView; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import app.morphe.extension.shared.settings.AppLanguage; +import app.morphe.extension.shared.ui.SheetBottomDialog; import app.morphe.extension.shared.utils.IntentUtils; import app.morphe.extension.shared.utils.Logger; import app.morphe.extension.youtube.settings.Settings; @@ -87,7 +82,6 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import static app.morphe.extension.shared.utils.StringRef.str; -import static app.morphe.extension.shared.utils.Utils.dipToPixels; import static app.morphe.extension.shared.utils.Utils.showToastLong; import static app.morphe.extension.shared.utils.Utils.showToastShort; @@ -192,10 +186,28 @@ public final class GeminiManager { private final ExecutorService metadataExecutor = Executors.newCachedThreadPool(); /** - * Reference to the progress dialog. + * Active loading bottom sheet for summarize/transcribe operations. */ @Nullable - private WeakReference progressDialogRef; + private GeminiBottomSheetUi.LoadingSheet loadingSheet; + + /** + * Active summary bottom sheet used after summarization completes. + */ + @Nullable + private GeminiBottomSheetUi.SummarySheet summarySheet; + + /** + * Active transcription bottom sheet used after Gemini transcription completes. + */ + @Nullable + private WeakReference transcriptionDialogRef; + + /** + * Active follow-up chat task inside the summary bottom sheet. + */ + @Nullable + private Future activeSummaryChatTask; /** * Flag indicating if the progress dialog is minimized. @@ -275,6 +287,11 @@ public final class GeminiManager { */ private final Map summaryModelCache = new ConcurrentHashMap<>(); + /** + * Cache for per-video summary follow-up chat history. + */ + private final Map> summaryConversationCache = new ConcurrentHashMap<>(); + /** * Cache for parsed transcriptions. */ @@ -405,19 +422,27 @@ public final class GeminiManager { prepareForNewOperationInternal(OperationType.SUMMARIZE, videoUrl); - final String apiKey = Settings.GEMINI_API_KEY.get(); - if (isEmptyApiKey(apiKey)) { + final List apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get()); + if (isEmptyApiKey(apiKeys)) { resetOperationStateInternal(OperationType.SUMMARIZE, true); return; } Logger.printDebug(() -> "Starting new summarization workflow: " + videoId); - activeTasks.put(taskKey, DUMMY_FUTURE); taskStartTimes.put(taskKey, System.currentTimeMillis()); showProgressDialogInternal(context, OperationType.SUMMARIZE); - GeminiUtils.getVideoSummary(videoUrl, apiKey, new GeminiUtils.Callback() { + Future summaryTask = GeminiUtils.getVideoSummary(videoUrl, apiKeys, new GeminiUtils.Callback() { + @Override + public void onPartial(String partialText, String accumulatedText, @Nullable String model) { + if (!activeTasks.containsKey(taskKey)) { + Logger.printDebug(() -> "Summary partial callback ignored - task was cancelled: " + taskKey); + return; + } + ensureMainThread(() -> handleSummaryStreamUpdate(videoUrl, accumulatedText)); + } + @Override public void onSuccess(String result) { onSuccessWithModel(result, null); @@ -443,6 +468,7 @@ public final class GeminiManager { handleApiResponseInternal(context, OperationType.SUMMARIZE, videoUrl, null, error); } }); + activeTasks.put(taskKey, summaryTask != null ? summaryTask : DUMMY_FUTURE); }); } @@ -605,8 +631,8 @@ public final class GeminiManager { updateTimerMessageInternal(); } - final String apiKey = Settings.GEMINI_API_KEY.get(); - if (isEmptyApiKey(apiKey)) { + final List apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get()); + if (isEmptyApiKey(apiKeys)) { activeTasks.remove(taskKey); taskStartTimes.remove(taskKey); if (Objects.equals(currentVideoUrl, videoUrl)) { @@ -618,10 +644,10 @@ public final class GeminiManager { // Call Gemini for translation String cleanedJson = YandexVotUtils.stripTokensFromYandexJson(rawIntermediateJson); - GeminiUtils.translateYandexJson( + Future translationTask = GeminiUtils.translateYandexJson( cleanedJson, targetLangName, - apiKey, + apiKeys, new GeminiUtils.Callback() { @Override public void onSuccess(String translatedJson) { @@ -644,6 +670,7 @@ public final class GeminiManager { } } ); + activeTasks.put(taskKey, translationTask != null ? translationTask : DUMMY_FUTURE); }); } @@ -697,11 +724,9 @@ public final class GeminiManager { isWaitingForYandexRetry = true; baseLoadingMessage = str("revanced_gemini_loading_yandex_transcribe") + "\n(" + statusMessage + ")"; - AlertDialog currentDialog = (progressDialogRef != null) ? progressDialogRef.get() : null; - - if (currentDialog != null && currentDialog.isShowing() && !isProgressDialogMinimized) { + if (loadingSheet != null && loadingSheet.isShowing() && !isProgressDialogMinimized) { updateTimerMessageInternal(); - } else if (progressDialogRef == null && !isProgressDialogMinimized) { + } else if (loadingSheet == null && !isProgressDialogMinimized) { showProgressDialogInternal(context, OperationType.TRANSCRIBE); } } else { @@ -889,8 +914,8 @@ public final class GeminiManager { Logger.printInfo(() -> "Starting new Gemini direct transcription workflow: " + videoId); String taskKey = getTaskKey(videoId, OperationType.TRANSCRIBE); - final String apiKey = Settings.GEMINI_API_KEY.get(); - if (isEmptyApiKey(apiKey)) { + final List apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get()); + if (isEmptyApiKey(apiKeys)) { resetOperationStateInternal(OperationType.TRANSCRIBE, true); taskStartTimes.remove(taskKey); return; @@ -898,7 +923,7 @@ public final class GeminiManager { showProgressDialogInternal(context, OperationType.TRANSCRIBE); - GeminiUtils.getVideoTranscription(videoUrl, apiKey, new GeminiUtils.Callback() { + Future transcriptionTask = GeminiUtils.getVideoTranscription(videoUrl, apiKeys, new GeminiUtils.Callback() { @Override public void onSuccess(String result) { if (!activeTasks.containsKey(taskKey)) { @@ -919,6 +944,7 @@ public final class GeminiManager { handleApiResponseInternal(context, OperationType.TRANSCRIBE, videoUrl, null, error); } }); + activeTasks.put(taskKey, transcriptionTask != null ? transcriptionTask : DUMMY_FUTURE); } /** @@ -962,6 +988,9 @@ public final class GeminiManager { private void prepareForNewOperationInternal(@NonNull OperationType newOperationType, @NonNull String newVideoUrl) { Logger.printDebug(() -> "Preparing UI for operation: " + newOperationType + " for URL: " + newVideoUrl); + dismissSummarySheetInternal(); + dismissTranscriptionDialogInternal(); + isCancelled = false; isProgressDialogMinimized = false; isWaitingForYandexRetry = false; @@ -1017,6 +1046,7 @@ public final class GeminiManager { if (opType == OperationType.SUMMARIZE) { summaryCache.put(videoId, result); summaryTimeCache.put(videoId, time); + summaryConversationCache.remove(videoId); if (!TextUtils.isEmpty(usedModel)) { summaryModelCache.put(videoId, usedModel); } else { @@ -1230,11 +1260,11 @@ public final class GeminiManager { /** * Checks if the API key is empty and shows a toast if so. * - * @param key The API key. + * @param apiKeys List of API keys. * @return True if the key is empty, false otherwise. */ - private boolean isEmptyApiKey(@Nullable String key) { - if (TextUtils.isEmpty(key)) { + private boolean isEmptyApiKey(@Nullable List apiKeys) { + if (apiKeys == null || apiKeys.isEmpty()) { showToastLong(str("revanced_gemini_error_no_api_key")); Logger.printDebug(() -> "isValidApiKey: API key is empty or null."); return true; @@ -1332,6 +1362,170 @@ public final class GeminiManager { return "English"; } + @Nullable + private String getVideoMetaText(@Nullable String videoUrl) { + if (TextUtils.isEmpty(videoUrl)) { + return null; + } + + String videoId = getVideoIdFromUrl(videoUrl); + String meta = videoMetadataCache.getOrDefault(videoId, ""); + if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) { + return meta; + } + return null; + } + + @Nullable + private String getCurrentVideoMetaText() { + return getVideoMetaText(currentVideoUrl); + } + + @Nullable + private String buildInfoText(int seconds, @Nullable String usedModel) { + StringBuilder infoText = new StringBuilder(); + if (!TextUtils.isEmpty(usedModel)) { + infoText.append(usedModel); + } + if (seconds >= 0) { + if (infoText.length() > 0) { + infoText.append('\n'); + } + infoText.append(str("revanced_gemini_time_taken", seconds)); + } + return infoText.length() == 0 ? null : infoText.toString(); + } + + @MainThread + private void handleSummaryStreamUpdate(@NonNull String videoUrl, @NonNull String accumulatedText) { + if (!Objects.equals(currentVideoUrl, videoUrl) || isProgressDialogMinimized || loadingSheet == null || !loadingSheet.isShowing()) { + return; + } + loadingSheet.updatePreview(accumulatedText); + } + + @MainThread + private void handleSummaryChatSend( + @NonNull String videoUrl, + @NonNull String summary, + @NonNull GeminiBottomSheetUi.SummarySheet sheet + ) { + if (summarySheet != sheet || !sheet.isShowing()) { + sheet.failPendingResponse(); + return; + } + + List apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get()); + if (isEmptyApiKey(apiKeys)) { + sheet.failPendingResponse(); + return; + } + + cancelActiveSummaryChatTaskInternal(); + + Future chatTask = GeminiUtils.chatWithVideo( + videoUrl, + summary, + sheet.getConversationWithPendingQuestion(), + apiKeys, + new GeminiUtils.Callback() { + @Override + public void onSuccess(String result) { + onSuccessWithModel(result, null); + } + + @Override + public void onPartial(String partialText, String accumulatedText, @Nullable String model) { + ensureMainThread(() -> { + if (summarySheet == sheet && sheet.isShowing()) { + sheet.updatePendingResponse(accumulatedText); + } + }); + } + + @Override + public void onSuccessWithModel(String result, @Nullable String model) { + ensureMainThread(() -> { + if (summarySheet == sheet && sheet.isShowing()) { + sheet.commitPendingResponse(normalizeGeminiText(result)); + } + activeSummaryChatTask = null; + }); + } + + @Override + public void onFailure(String error) { + ensureMainThread(() -> { + if (summarySheet == sheet && sheet.isShowing()) { + sheet.failPendingResponse(); + } + activeSummaryChatTask = null; + if (!"Operation cancelled.".equals(error)) { + showToastLong(str("revanced_gemini_error_api_failed", error)); + } + }); + } + } + ); + + if (chatTask == null) { + sheet.failPendingResponse(); + return; + } + activeSummaryChatTask = chatTask; + } + + @MainThread + private void cancelActiveSummaryChatTaskInternal() { + boolean hadActiveTask = activeSummaryChatTask != null; + if (hadActiveTask) { + try { + activeSummaryChatTask.cancel(true); + } catch (Exception e) { + Logger.printException(() -> "Error cancelling Gemini summary chat task", e); + } finally { + activeSummaryChatTask = null; + } + } + + if (hadActiveTask && summarySheet != null && summarySheet.isShowing()) { + summarySheet.failPendingResponse(); + } + } + + @MainThread + private void dismissSummarySheetInternal() { + cancelActiveSummaryChatTaskInternal(); + + if (summarySheet != null) { + try { + if (summarySheet.isShowing()) { + summarySheet.dismiss(); + } + } catch (Exception e) { + Logger.printException(() -> "Error dismissing Gemini summary sheet", e); + } finally { + summarySheet = null; + } + } + } + + @MainThread + private void dismissTranscriptionDialogInternal() { + if (transcriptionDialogRef != null) { + SheetBottomDialog.SlideDialog dialog = transcriptionDialogRef.get(); + if (dialog != null && dialog.isShowing()) { + try { + dialog.dismiss(); + } catch (Exception e) { + Logger.printException(() -> "Error dismissing Gemini transcription sheet", e); + } + } + transcriptionDialogRef.clear(); + transcriptionDialogRef = null; + } + } + // endregion Utility Methods // region UI Methods: Dialogs @@ -1362,97 +1556,55 @@ public final class GeminiManager { } rebuildBaseLoadingMessage(opType); - String initialMsg = (baseLoadingMessage != null && !baseLoadingMessage.isEmpty()) - ? baseLoadingMessage - : str("revanced_gemini_loading_default"); - - String timeSuffix = ""; - int elapsedSeconds = calculateElapsedTimeSeconds(); - if (elapsedSeconds >= 0) { - timeSuffix = "\n" + elapsedSeconds + "s"; - } - - String metaPrefix = ""; - if (currentVideoUrl != null) { - String videoId = getVideoIdFromUrl(currentVideoUrl); - String meta = videoMetadataCache.getOrDefault(videoId, ""); - if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) { - metaPrefix = meta + "\n\n"; - } - } - - AlertDialog.Builder builder = new AlertDialog.Builder(context); - - if (opType == OperationType.SUMMARIZE) { - builder.setTitle(str("revanced_gemini_summary_title")); - } else { - builder.setTitle(str("revanced_gemini_loading_default")); - } - - builder.setMessage(metaPrefix + initialMsg + timeSuffix); - builder.setCancelable(false); - - // Cancel Button - builder.setNegativeButton(str("revanced_cancel"), (d, w) -> { - final String urlAtClick = currentVideoUrl; - - ensureMainThread(() -> { - if (urlAtClick == null) { - try {d.dismiss();} catch (Exception ignored) {} - return; - } - - if (Objects.equals(currentVideoUrl, urlAtClick)) { - Logger.printInfo(() -> opType + " UI cancelled by user for " + urlAtClick); - isCancelled = true; - - String videoId = getVideoIdFromUrl(urlAtClick); - String taskKey = getTaskKey(videoId, opType); - - Future f = activeTasks.remove(taskKey); - - if (f != null) { - try { - f.cancel(true); - } catch (Exception e) { - Logger.printException(() -> "Error cancelling future for task: " + taskKey, e); - } - } - - if (opType == OperationType.TRANSCRIBE) { - YandexVotUtils.forceReleaseWorkflowLock(urlAtClick); - } - - resetOperationStateInternal(opType, true); - showToastShort(str("revanced_gemini_cancelled")); - } - }); - }); - - // Minimize Button - builder.setNeutralButton(str("revanced_minimize"), (d, w) -> { - final String urlAtClick = currentVideoUrl; - - ensureMainThread(() -> { - assert urlAtClick != null; - if (Objects.equals(currentVideoUrl, urlAtClick) && !isProgressDialogMinimized) { - isProgressDialogMinimized = true; - stopTimerInternal(); - dismissProgressDialogInternal(); - } - }); - }); - try { - AlertDialog dialog = builder.create(); - dialog.show(); + final String statusText = (baseLoadingMessage != null && !baseLoadingMessage.isEmpty()) + ? baseLoadingMessage + : str("revanced_gemini_loading_default"); + final String videoUrlAtShow = currentVideoUrl; - Window window = dialog.getWindow(); - if (window != null) { - window.setLayout((int) (context.getResources().getDisplayMetrics().widthPixels * 0.90), WindowManager.LayoutParams.WRAP_CONTENT); - } + loadingSheet = new GeminiBottomSheetUi.LoadingSheet( + context, + opType == OperationType.SUMMARIZE + ? str("revanced_gemini_summary_title") + : str("revanced_gemini_loading_default"), + getCurrentVideoMetaText(), + statusText, + () -> ensureMainThread(() -> { + if (videoUrlAtShow == null) { + return; + } + if (Objects.equals(currentVideoUrl, videoUrlAtShow)) { + Logger.printInfo(() -> opType + " UI cancelled by user for " + videoUrlAtShow); + isCancelled = true; - progressDialogRef = new WeakReference<>(dialog); + String videoId = getVideoIdFromUrl(videoUrlAtShow); + String taskKey = getTaskKey(videoId, opType); + Future future = activeTasks.remove(taskKey); + if (future != null) { + try { + future.cancel(true); + } catch (Exception e) { + Logger.printException(() -> "Error cancelling future for task: " + taskKey, e); + } + } + + if (opType == OperationType.TRANSCRIBE) { + YandexVotUtils.forceReleaseWorkflowLock(videoUrlAtShow); + } + + resetOperationStateInternal(opType, true); + showToastShort(str("revanced_gemini_cancelled")); + } + }), + () -> ensureMainThread(() -> { + if (Objects.equals(currentVideoUrl, videoUrlAtShow) && !isProgressDialogMinimized) { + isProgressDialogMinimized = true; + stopTimerInternal(); + loadingSheet = null; + } + }) + ); + updateTimerMessageInternal(); startTimerInternal(); } catch (Exception e) { Logger.printException(() -> "Error showing progress dialog for " + opType, e); @@ -1471,25 +1623,16 @@ public final class GeminiManager { @MainThread private void dismissProgressDialogInternal() { stopTimerInternal(); - - if (progressDialogRef != null) { - AlertDialog dialog = progressDialogRef.get(); - if (dialog != null && dialog.isShowing()) { - try { - Context ctx = dialog.getContext(); - if (ctx instanceof Activity activity) { - if (!activity.isFinishing() && !activity.isDestroyed()) { - dialog.dismiss(); - } - } else { - dialog.dismiss(); - } - } catch (Exception e) { - Logger.printException(() -> "Error dismissing progress dialog", e); + if (loadingSheet != null) { + try { + if (loadingSheet.isShowing()) { + loadingSheet.dismiss(); } + } catch (Exception e) { + Logger.printException(() -> "Error dismissing progress dialog", e); + } finally { + loadingSheet = null; } - progressDialogRef.clear(); - progressDialogRef = null; } } @@ -1505,71 +1648,43 @@ public final class GeminiManager { */ @MainThread private void showSummaryDialog(@NonNull Context context, @NonNull String summary, int seconds, @NonNull String videoUrl, @Nullable String usedModel) { - StringBuilder summaryMetaInfo = new StringBuilder(); - - if (!TextUtils.isEmpty(usedModel)) summaryMetaInfo.append(usedModel).append('\n'); - if (seconds >= 0) summaryMetaInfo.append(str("revanced_gemini_time_taken", seconds)); - String timeMsg = summaryMetaInfo.length() > 0 ? "\n\n" + summaryMetaInfo : ""; - - // Prepare the header (Metadata) String videoId = getVideoIdFromUrl(videoUrl); - String meta = videoMetadataCache.getOrDefault(videoId, ""); - String metaPrefix = ""; - if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) { - metaPrefix = meta + "\n\n"; - } + String normalizedSummary = normalizeGeminiText(summary); + CharSequence formattedSummary = formatGeminiText(normalizedSummary, videoUrl); + List cachedConversation = new ArrayList<>( + Objects.requireNonNull(summaryConversationCache.getOrDefault(videoId, Collections.emptyList())) + ); - String normalizedSummary = summary.replace("\r\n", "\n").trim(); + dismissSummarySheetInternal(); + summarySheet = new GeminiBottomSheetUi.SummarySheet( + context, + getVideoMetaText(videoUrl), + buildInfoText(seconds, usedModel), + formattedSummary, + normalizedSummary, + cachedConversation, + text -> formatGeminiText(text, videoUrl), + text -> setClipboard(context, normalizeGeminiText(text), str("revanced_gemini_copy_success")), + history -> summaryConversationCache.put(videoId, new ArrayList<>(history)), + () -> ensureMainThread(() -> { + cancelActiveSummaryChatTaskInternal(); + summarySheet = null; + }), + (sheet, question) -> ensureMainThread(() -> handleSummaryChatSend(videoUrl, normalizedSummary, sheet)) + ); + } - String displaySummary = wrapTimestampReferencesForPillStyle(normalizedSummary); - Spanned formattedSummary = MarkdownUtils.fromMarkdown(displaySummary); - SpannableStringBuilder clickableSummary = createClickableTimestampSummary(formattedSummary, videoUrl); + @NonNull + private String normalizeGeminiText(@NonNull String text) { + return text.replace("\r\n", "\n").trim(); + } - SpannableStringBuilder summaryMessage = new SpannableStringBuilder(); - if (!metaPrefix.isEmpty()) { - summaryMessage.append(metaPrefix); - } - summaryMessage.append(clickableSummary); - summaryMessage.append(timeMsg); - - TextView summaryTextView = new TextView(context); - summaryTextView.setText(summaryMessage); - summaryTextView.setTextColor(ThemeUtils.getAppForegroundColor()); - summaryTextView.setTextSize(16f); - summaryTextView.setMovementMethod(LinkMovementMethod.getInstance()); - summaryTextView.setLinksClickable(true); - - LinearLayout contentLayout = new LinearLayout(context); - contentLayout.setOrientation(LinearLayout.VERTICAL); - contentLayout.setPadding(dipToPixels(20), dipToPixels(10), dipToPixels(20), dipToPixels(8)); - contentLayout.addView(summaryTextView, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - )); - - ScrollView scrollView = new ScrollView(context); - scrollView.setVerticalScrollBarEnabled(false); - scrollView.addView(contentLayout, new ScrollView.LayoutParams( - ScrollView.LayoutParams.MATCH_PARENT, - ScrollView.LayoutParams.WRAP_CONTENT - )); - - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(str("revanced_gemini_summary_title")); - builder.setView(scrollView); - - AlertDialog dialog = builder - .setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss()) - .setNeutralButton(str("revanced_copy"), (d, w) -> setClipboard(context, normalizedSummary, str("revanced_gemini_copy_success"))) - .setCancelable(true) - .create(); - - dialog.show(); - - Window window = dialog.getWindow(); - if (window != null) { - window.setLayout((int) (context.getResources().getDisplayMetrics().widthPixels * 0.90), WindowManager.LayoutParams.WRAP_CONTENT); - } + @NonNull + private CharSequence formatGeminiText(@NonNull String text, @NonNull String videoUrl) { + String normalizedText = normalizeGeminiText(text); + String displayText = wrapTimestampReferencesForPillStyle(normalizedText); + Spanned formattedText = MarkdownUtils.fromMarkdown(displayText); + return createClickableTimestampText(formattedText, videoUrl); } @NonNull @@ -1600,14 +1715,14 @@ public final class GeminiManager { } @NonNull - private SpannableStringBuilder createClickableTimestampSummary(@NonNull Spanned formattedSummary, @NonNull String videoUrl) { - SpannableStringBuilder clickableSummary = new SpannableStringBuilder(formattedSummary); + private SpannableStringBuilder createClickableTimestampText(@NonNull Spanned formattedText, @NonNull String videoUrl) { + SpannableStringBuilder clickableText = new SpannableStringBuilder(formattedText); String videoId = getVideoIdFromUrl(videoUrl); if (TextUtils.isEmpty(videoId)) { - return clickableSummary; + return clickableText; } - Matcher matcher = SUMMARY_TIMESTAMP_PATTERN.matcher(clickableSummary.toString()); + Matcher matcher = SUMMARY_TIMESTAMP_PATTERN.matcher(clickableText.toString()); while (matcher.find()) { final long timestampSeconds; try { @@ -1617,14 +1732,14 @@ public final class GeminiManager { } String vndLink = String.format(Locale.ENGLISH, VIDEO_SCHEME_INTENT_FORMAT, videoId, timestampSeconds); - clickableSummary.setSpan( + clickableText.setSpan( new TimestampClickableSpan(vndLink), matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); } - return clickableSummary; + return clickableText; } private long parseTimestampToSeconds(@NonNull Matcher matcher) { @@ -1677,40 +1792,21 @@ public final class GeminiManager { @MainThread private void showTranscriptionResultDialogInternal(@NonNull Context context, @NonNull String rawTranscription, int seconds, @NonNull String videoUrl) { Logger.printDebug(() -> "Showing raw Gemini transcription dialog."); - String timeMsg = (seconds >= 0) ? "\n\n" + str("revanced_gemini_time_taken", seconds) : ""; - - String metaPrefix = ""; - String videoId = getVideoIdFromUrl(videoUrl); - String meta = videoMetadataCache.getOrDefault(videoId, ""); - if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) { - metaPrefix = meta + "\n\n"; - } - - String msg = metaPrefix + rawTranscription + timeMsg; - final String dialogVideoUrl = videoUrl; - - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(str("revanced_gemini_transcription_result_title")); - - AlertDialog dialog = builder.setMessage(msg) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss()) - .setNegativeButton(str("revanced_copy"), (d, w) -> setClipboard(context, rawTranscription, str("revanced_gemini_copy_success"))) - .setNeutralButton(str("revanced_gemini_transcription_parse_button"), (d, which) -> ensureMainThread(() -> { + dismissTranscriptionDialogInternal(); + SheetBottomDialog.SlideDialog dialog = GeminiBottomSheetUi.showTranscriptionSheet( + context, + getVideoMetaText(videoUrl), + buildInfoText(seconds, null), + rawTranscription, + () -> setClipboard(context, rawTranscription, str("revanced_gemini_copy_success")), + () -> ensureMainThread(() -> { Logger.printDebug(() -> "Parse button clicked (Gemini raw). Attempting parse and display overlay."); hideTranscriptionOverlayInternal(); parseAndShowTranscriptionInternal(rawTranscription, dialogVideoUrl); - try { d.dismiss(); } catch (Exception ignored) {} - })) - .create(); - - dialog.show(); - - Window window = dialog.getWindow(); - if (window != null) { - window.setLayout((int) (context.getResources().getDisplayMetrics().widthPixels * 0.90), WindowManager.LayoutParams.WRAP_CONTENT); - } + }) + ); + transcriptionDialogRef = new WeakReference<>(dialog); } // endregion UI Methods: Dialogs @@ -2009,13 +2105,11 @@ public final class GeminiManager { */ @MainThread private void startTimerInternal() { - AlertDialog currentDialog = (progressDialogRef != null) ? progressDialogRef.get() : null; - if (currentOperation == OperationType.NONE || isCancelled || isProgressDialogMinimized - || currentDialog == null - || !currentDialog.isShowing()) { + || loadingSheet == null + || !loadingSheet.isShowing()) { stopTimerInternal(); return; } @@ -2025,8 +2119,11 @@ public final class GeminiManager { timerRunnable = new Runnable() { @Override public void run() { - AlertDialog currentDialog = progressDialogRef.get(); - if (currentOperation != OperationType.NONE && !isCancelled && !isProgressDialogMinimized && currentDialog != null && currentDialog.isShowing()) { + if (currentOperation != OperationType.NONE + && !isCancelled + && !isProgressDialogMinimized + && loadingSheet != null + && loadingSheet.isShowing()) { updateTimerMessageInternal(); timerHandler.postDelayed(this, 1000); } else { @@ -2057,40 +2154,24 @@ public final class GeminiManager { */ @MainThread private void updateTimerMessageInternal() { - if (progressDialogRef == null) { + if (loadingSheet == null) { stopTimerInternal(); return; } - AlertDialog currentDialog = progressDialogRef.get(); - - if (currentDialog != null && currentDialog.isShowing() && !isCancelled && !isProgressDialogMinimized) { + if (loadingSheet.isShowing() && !isCancelled && !isProgressDialogMinimized) { int sec = calculateElapsedTimeSeconds(); - if (sec < 0) return; + if (sec < 0) { + return; + } - String time = "\n" + sec + "s"; String base = (baseLoadingMessage != null && !baseLoadingMessage.isEmpty()) ? baseLoadingMessage : str("revanced_gemini_loading_default"); - - String metaPrefix = ""; - if (currentVideoUrl != null) { - String videoId = getVideoIdFromUrl(currentVideoUrl); - String meta = videoMetadataCache.getOrDefault(videoId, ""); - if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) { - metaPrefix = meta + "\n\n"; - } - } - - String msg = metaPrefix + base + time; - try { - TextView tv = currentDialog.findViewById(android.R.id.message); - if (tv != null) { - tv.setText(msg); - } else { - currentDialog.setMessage(msg); - } + loadingSheet.updateMeta(getCurrentVideoMetaText()); + loadingSheet.updateStatus(base); + loadingSheet.updateElapsed(sec); } catch (Exception e) { stopTimerInternal(); } diff --git a/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiUtils.java b/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiUtils.java index 94a35dc0d..e66f1c8c6 100644 --- a/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiUtils.java +++ b/extensions/shared/src/main/java/app/morphe/extension/youtube/utils/GeminiUtils.java @@ -43,10 +43,10 @@ package app.morphe.extension.youtube.utils; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import app.morphe.extension.shared.utils.Logger; -import app.morphe.extension.youtube.settings.Settings; + import org.jetbrains.annotations.NotNull; import org.json.JSONArray; import org.json.JSONException; @@ -59,9 +59,16 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import app.morphe.extension.shared.utils.Logger; +import app.morphe.extension.youtube.settings.Settings; public class GeminiUtils { private static final ExecutorService executor = Executors.newCachedThreadPool(); @@ -74,7 +81,8 @@ public class GeminiUtils { "gemini-2.5-flash", "gemini-2.5-flash-lite", }; - private static final String ACTION = ":generateContent?key="; + private static final String GENERATE_ACTION = ":generateContent?key="; + private static final String STREAM_ACTION = ":streamGenerateContent?alt=sse&key="; private static final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); @@ -82,332 +90,621 @@ public class GeminiUtils { * Initiates an asynchronous request to the Gemini API to generate a summary for the given video URL. * * @param videoUrl The publicly accessible URL of the video to summarize. - * @param apiKey The Gemini API key. + * @param apiKeys Gemini API keys, checked top-to-bottom for each model. * @param callback The {@link Callback} to receive the summary result or an error message. + * @return The running request future. */ - public static void getVideoSummary(@NonNull String videoUrl, @NonNull String apiKey, @NonNull Callback callback) { + @Nullable + public static Future getVideoSummary( + @NonNull String videoUrl, + @NonNull List apiKeys, + @NonNull Callback callback + ) { String promptForGemini25 = getPrompt(); String prompt = promptForGemini25 + " Include timestamp references at the end of each summary section in this format: [MM:SS – MM:SS], or if the video is an hour long or longer: [HH:MM:SS – HH:MM:SS]. Timestamps must be accurate (hours:minutes:seconds) and double-checked. Please verify the correct format is used."; Logger.printDebug(() -> "GeminiUtils (SUMMARY): Sending Prompt (Gemini 3.x base): " + prompt); - generateContent(videoUrl, apiKey, prompt, promptForGemini25, GEMINI_MODELS[0], 0, callback); - } - - @NonNull - private static String getPrompt() { - String langName = getLanguageName(); - return "Write a spoiler‑heavy summary of this video in " + langName + ". Focus on key events in chronological order. Use Markdown to make it readable, and mark important details with bold text. Output only the summary—do not include a preamble, greetings, or phrases like \"Here is the summary.\" If the video contains sponsored segments, exclude them entirely. Add a TL;DR at the beginning that gives only the most important information. Then provide the summary, separated from the TL;DR."; + return executeRequest(RequestSpec.forPrompt(videoUrl, prompt, promptForGemini25, true, false), apiKeys, callback); } /** * Initiates an asynchronous request to the Gemini API to generate a transcription for the given video URL. * * @param videoUrl The publicly accessible URL of the video to transcribe. - * @param apiKey The Gemini API key. + * @param apiKeys Gemini API keys, checked top-to-bottom for each model. * @param callback The {@link Callback} to receive the transcription result or an error message. + * @return The running request future. */ - public static void getVideoTranscription(@NonNull String videoUrl, @NonNull String apiKey, @NonNull Callback callback) { + @Nullable + public static Future getVideoTranscription( + @NonNull String videoUrl, + @NonNull List apiKeys, + @NonNull Callback callback + ) { String langName = getLanguageName(); - String prompt = "Transcribe this video precisely in " + langName + ", including spoken words, written words in the video and significant sounds. Provide timestamps for each segment in the format [HH:MM:SS.mmm - HH:MM:SS.mmm]: Text. Skip any preamble, intro phrases, or explanations — output only the transcription."; + String prompt = "Transcribe this video precisely in " + langName + ", including spoken words, written words in the video and significant sounds. Provide timestamps for each segment in the format [HH:MM:SS.mmm - HH:MM:SS.mmm]: Text. Skip any preamble, intro phrases, or explanations - output only the transcription."; Logger.printDebug(() -> "GeminiUtils (TRANSCRIPTION): Sending Prompt: " + prompt); - generateContent(videoUrl, apiKey, prompt, null, GEMINI_MODELS[0], 0, callback); + return executeRequest(RequestSpec.forPrompt(videoUrl, prompt, null, false, false), apiKeys, callback); } /** - * Initiates an asynchronous request to the Gemini API to translate the text content - * within a Yandex-formatted subtitle JSON string to a specified target language. + * Initiates an asynchronous request to the Gemini API to translate Yandex subtitle JSON. * - * @param yandexJson The non-null JSON string containing subtitles in the Yandex format - * (typically an array of objects or an object containing a "subtitles" array, - * where objects have "startMs", "endMs"/"durationMs", and "text" keys). - * While basic JSON validity isn't checked here, grossly invalid input - * might lead to Gemini errors or unexpected results. - * @param targetLangName The non-null display name (in English, e.g., "Spanish", "German") - * of the language to translate the subtitle text into. This name is used - * directly in the prompt sent to the Gemini API. Use a reliable method - * like {@link java.util.Locale#getDisplayLanguage(Locale)} to obtain this. - * @param apiKey The non-null Google AI API key (Gemini API key). - * @param callback The non-null {@link Callback} interface instance to receive the - * result. {@link Callback#onSuccess(String)} will be called with the - * translated JSON string, and {@link Callback#onFailure(String)} - * will be called if an error occurs during the API request, - * response parsing, or if the result format is invalid. + * @param yandexJson The Yandex-formatted subtitle JSON. + * @param targetLangName The target language display name. + * @param apiKeys Gemini API keys, checked top-to-bottom for each model. + * @param callback Callback receiving the translated JSON or an error. + * @return The running request future. */ - public static void translateYandexJson( + @Nullable + public static Future translateYandexJson( @NonNull String yandexJson, @NonNull String targetLangName, - @NonNull String apiKey, - @NonNull Callback callback) { + @NonNull List apiKeys, + @NonNull Callback callback + ) { String prompt = "Translate ONLY the string values associated with the \"text\" keys within the following JSON subtitle data to " + targetLangName + ". Preserve the exact JSON structure, including all keys (like \"startMs\", \"endMs\", \"durationMs\") and their original numeric values. Output ONLY the fully translated JSON data, without any introductory text, explanations, comments, or markdown formatting (like ```json ... ```).\n\nInput JSON:\n" + yandexJson; Logger.printDebug(() -> "GeminiUtils (JSON TRANSLATE): Sending Translation Prompt for target '" + targetLangName + "'."); - generateContent(null, apiKey, prompt, null, GEMINI_MODELS[0], 0, callback); + return executeRequest(RequestSpec.forPrompt(null, prompt, null, false, true), apiKeys, callback); } /** - * Makes an asynchronous POST request to the Gemini API's generateContent endpoint. + * Initiates a streamed multi-turn follow-up chat about a summarized video. * - * @param videoUrl The publicly accessible URL of the video (nullable). - * @param apiKey The Gemini API key. - * @param textPrompt The specific text prompt to send. - * @param promptForGemini25 Optional prompt override used specifically for Gemini 2.5 models. - * @param model The Gemini model to use. - * @param modelIndex The index of the model in {@link #GEMINI_MODELS}. - * @param callback The {@link Callback} to handle the success or failure response. + * @param videoUrl The video URL used as the multimodal context. + * @param summary The generated summary that seeds the chat. + * @param history Conversation history including the latest user question. + * @param apiKeys Gemini API keys, checked top-to-bottom for each model. + * @param callback Callback receiving streamed chunks, the final answer, or an error. + * @return The running request future. */ - private static void generateContent(@Nullable String videoUrl, @NonNull String apiKey, @NonNull String textPrompt, @Nullable String promptForGemini25, @NonNull String model, int modelIndex, @NonNull Callback callback) { - executor.submit(() -> { - HttpURLConnection connection = null; - - try { - final int attemptNumber = modelIndex + 1; - Logger.printInfo(() -> "GeminiUtils: Attempting model [" + attemptNumber + "/" + GEMINI_MODELS.length + "]: " + model); - final String effectivePrompt = resolvePromptForModel(model, textPrompt, promptForGemini25); - if (!effectivePrompt.equals(textPrompt)) { - Logger.printDebug(() -> "GeminiUtils: Using model-specific prompt for " + model + "."); - } - - URL url = new URL(BASE_API_URL + model + ACTION + apiKey); - connection = (HttpURLConnection) url.openConnection(); - - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", "application/json; utf-8"); - connection.setRequestProperty("Accept", "application/json"); - connection.setDoOutput(true); - connection.setConnectTimeout(30000); - connection.setReadTimeout(6000000); - - JSONObject requestBody = new JSONObject(); - JSONArray contentsArray = new JSONArray(); - JSONObject content = new JSONObject(); - JSONArray partsArray = new JSONArray(); - - if (videoUrl != null) { - Logger.printDebug(() -> "GeminiUtils: Constructing payload WITH video part."); - JSONObject fileData = new JSONObject() - .put("mimeType", "video/mp4") - .put("fileUri", videoUrl); - JSONObject videoPart = new JSONObject().put("fileData", fileData); - partsArray.put(videoPart); - - JSONObject textPart = new JSONObject().put("text", effectivePrompt); - partsArray.put(textPart); - } else { - Logger.printDebug(() -> "GeminiUtils: Constructing payload with ONLY text part."); - JSONObject textPartOnly = new JSONObject().put("text", effectivePrompt); - partsArray.put(textPartOnly); - } - - content.put("parts", partsArray); - contentsArray.put(content); - requestBody.put("contents", contentsArray); - - /* Uncomment to enable safety settings - JSONObject safetySetting_harassment = new JSONObject().put("category", "HARM_CATEGORY_HARASSMENT").put("threshold", "BLOCK_MEDIUM_AND_ABOVE"); - JSONObject safetySetting_hate = new JSONObject().put("category", "HARM_CATEGORY_HATE_SPEECH").put("threshold", "BLOCK_MEDIUM_AND_ABOVE"); - JSONObject safetySetting_sex = new JSONObject().put("category", "HARM_CATEGORY_SEXUALLY_EXPLICIT").put("threshold", "BLOCK_MEDIUM_AND_ABOVE"); - JSONObject safetySetting_danger = new JSONObject().put("category", "HARM_CATEGORY_DANGEROUS_CONTENT").put("threshold", "BLOCK_MEDIUM_AND_ABOVE"); - JSONArray safetySettingsArray = new JSONArray() - .put(safetySetting_harassment) - .put(safetySetting_hate) - .put(safetySetting_sex) - .put(safetySetting_danger); - requestBody.put("safetySettings", safetySettingsArray); - */ - - // Configure generation options based on the model - JSONObject generationConfig = new JSONObject(); - JSONObject thinkingConfig; - - if (model.startsWith("gemini-3")) { - // Gemini 3.x configuration: keep thinking minimal. - thinkingConfig = new JSONObject() - .put("thinkingLevel", "minimal") - .put("includeThoughts", false); - } else { - // Gemini 2.5 configuration: disable thinking. - thinkingConfig = new JSONObject().put("thinkingBudget", 0); - } - generationConfig.put("thinkingConfig", thinkingConfig); - - if (generationConfig.length() > 0) { - requestBody.put("generationConfig", generationConfig); - } - - String jsonInputString = requestBody.toString(); - - String logIdentifier = (videoUrl != null) ? "VIDEO" : "TEXT/JSON"; - Logger.printDebug(() -> "GeminiUtils (" + logIdentifier + " - " + model + "): Sending Payload. Prompt starts: " + effectivePrompt.substring(0, Math.min(effectivePrompt.length(), 200)) + "..."); - - try (OutputStream os = connection.getOutputStream()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); - } - - if (Thread.currentThread().isInterrupted()) { - throw new InterruptedException("Gemini task cancelled before reading response."); - } - - int responseCode = connection.getResponseCode(); - StringBuilder response = new StringBuilder(); - try (BufferedReader reader = getBufferedReader(responseCode, connection)) { - String responseLine; - while ((responseLine = reader.readLine()) != null) { - response.append(responseLine.trim()); - if (Thread.currentThread().isInterrupted()) { - throw new InterruptedException("Gemini task cancelled while reading response."); - } - } - } - - String responseString = response.toString(); - if (responseCode == HttpURLConnection.HTTP_OK) { - Logger.printInfo(() -> "GeminiUtils: Model succeeded: " + model + " (HTTP 200)"); - JSONObject jsonResponse = new JSONObject(responseString); - try { - if (!jsonResponse.has("candidates") || jsonResponse.getJSONArray("candidates").length() == 0) { - String blockReason = extractBlockReason(jsonResponse); - final String finalBlockReason = blockReason != null ? "Content blocked: " + blockReason : - "API response missing valid candidates. Full Response: " + responseString.substring(0, Math.min(responseString.length(), 500)) + "..."; - if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned no valid candidates")) { - return; - } - Logger.printException(() -> "Gemini API Error (" + model + "): " + finalBlockReason); - mainThreadHandler.post(() -> callback.onFailure(finalBlockReason)); - return; - } - - String resultText = jsonResponse.getJSONArray("candidates") - .getJSONObject(0) - .getJSONObject("content") - .getJSONArray("parts") - .getJSONObject(0) - .getString("text"); - - final String finalResult = sanitizeJsonOutput(resultText); - Logger.printDebug(() -> "Gemini RAW result (Sanitized): " + finalResult.substring(0, Math.min(finalResult.length(), 300)) + "..."); - - if (videoUrl == null) { - boolean looksLikeJson = finalResult.startsWith("[") || finalResult.startsWith("{"); - if (!looksLikeJson) { - Logger.printInfo(() -> "Gemini JSON translation result doesn't look like valid JSON!"); - Logger.printInfo(() -> "Start of content: " + finalResult.substring(0, Math.min(finalResult.length(), 50))); - final String jsonError = "Translation result format error. Expected JSON."; - if (tryFallback(null, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned non-JSON translation output")) { - return; - } - mainThreadHandler.post(() -> callback.onFailure(jsonError)); - return; - } - } - - Logger.printDebug(() -> "Gemini RAW result received: " + finalResult.substring(0, Math.min(finalResult.length(), 300)) + "..."); - - if (videoUrl == null) { - boolean looksLikeJson = finalResult.startsWith("[") || finalResult.startsWith("{"); - if (!looksLikeJson) { - Logger.printInfo(() -> "Gemini JSON translation result doesn't look like valid JSON!"); - final String jsonError = "Translation result format error. Expected JSON."; - if (tryFallback(null, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned non-JSON translation output")) { - return; - } - mainThreadHandler.post(() -> callback.onFailure(jsonError)); - return; - } - } - - mainThreadHandler.post(() -> callback.onSuccessWithModel(finalResult, model)); - } catch (JSONException jsonEx) { - if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned unparsable JSON payload")) { - return; - } - Logger.printException(() -> "Gemini API Response JSON Parsing Error (" + model + "). Full Response: " + responseString.substring(0, Math.min(responseString.length(), 500)) + "...", jsonEx); - String blockReason = extractBlockReason(jsonResponse); - final String finalError = blockReason != null ? "Content blocked: " + blockReason : - "Failed to parse result from API response. Check logs."; - mainThreadHandler.post(() -> callback.onFailure(finalError)); - } - } else { - if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "failed with HTTP " + responseCode)) { - return; - } - - String errorMessage = "HTTP Error: " + responseCode; - String errorDetails = " - " + responseString.substring(0, Math.min(responseString.length(), 200)) + "..."; - try { - JSONObject errorResponse = new JSONObject(responseString); - if (errorResponse.has("error") && errorResponse.getJSONObject("error").has("message")) { - errorMessage = errorResponse.getJSONObject("error").getString("message"); - } else { - errorMessage += errorDetails; - } - } catch (Exception jsonEx) { - errorMessage += errorDetails; - } - Logger.printException(() -> "Gemini API Error (" + model + ", HTTP " + responseCode + "). Response: " + responseString); - final String finalError = errorMessage; - mainThreadHandler.post(() -> callback.onFailure(finalError)); - } - - } catch (java.net.SocketTimeoutException e) { - if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "timed out")) { - return; - } - - Logger.printException(() -> "Gemini API request timed out (" + model + ")", e); - final String timeoutMsg = "Request timed out after " + (connection != null ? connection.getReadTimeout() / 1000 : "?") + " seconds."; - mainThreadHandler.post(() -> callback.onFailure(timeoutMsg)); - } catch (InterruptedException e) { - Logger.printInfo(() -> "Gemini task explicitly cancelled."); - mainThreadHandler.post(() -> callback.onFailure("Operation cancelled.")); - Thread.currentThread().interrupt(); - } catch (IOException e) { - if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "network request failed")) { - return; - } - - Logger.printException(() -> "Gemini API request IO failed (" + model + ")", e); - final String ioErrorMsg = e.getMessage() != null ? "Network error: " + e.getMessage() : "Unknown network error"; - mainThreadHandler.post(() -> callback.onFailure(ioErrorMsg)); - } catch (Exception e) { - if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "failed with exception: " + e.getClass().getSimpleName())) { - return; - } - - Logger.printException(() -> "Gemini API request failed (" + model + ")", e); - final String genericErrorMsg = e.getMessage() != null ? e.getMessage() : "Unknown error during request setup"; - mainThreadHandler.post(() -> callback.onFailure(genericErrorMsg)); - } finally { - if (connection != null) { - connection.disconnect(); - } - } - }); - } - - private static boolean tryFallback( - @Nullable String videoUrl, - @NonNull String apiKey, - @NonNull String textPrompt, - @Nullable String promptForGemini25, - @NonNull String currentModel, - int currentModelIndex, - @NonNull Callback callback, - @NonNull String reason + @Nullable + public static Future chatWithVideo( + @NonNull String videoUrl, + @NonNull String summary, + @NonNull List history, + @NonNull List apiKeys, + @NonNull Callback callback ) { - int nextModelIndex = currentModelIndex + 1; - if (nextModelIndex >= GEMINI_MODELS.length) { - Logger.printDebug(() -> "GeminiUtils: No fallback left after model " + currentModel + " (" + reason + ")."); - return false; - } - - String nextModel = GEMINI_MODELS[nextModelIndex]; - Logger.printDebug(() -> "GeminiUtils: Model " + currentModel + " " + reason + ". Falling back to " + nextModel + "."); - generateContent(videoUrl, apiKey, textPrompt, promptForGemini25, nextModel, nextModelIndex, callback); - return true; + return executeRequest(RequestSpec.forChat(videoUrl, summary, history), apiKeys, callback); } @NonNull - private static String resolvePromptForModel(@NonNull String model, @NonNull String defaultPrompt, @Nullable String promptForGemini25) { + public static List parseApiKeys(@Nullable String rawValue) { + LinkedHashSet apiKeys = new LinkedHashSet<>(); + if (rawValue == null) { + return new ArrayList<>(); + } + + for (String candidate : rawValue.split("[\\r\\n,]+")) { + String trimmed = candidate.trim(); + if (!trimmed.isEmpty()) { + apiKeys.add(trimmed); + } + } + return new ArrayList<>(apiKeys); + } + + @NonNull + public static String maskApiKey(@Nullable String apiKey) { + if (TextUtils.isEmpty(apiKey)) { + return "(empty)"; + } + + String trimmed = apiKey.trim(); + int suffixLength = Math.min(6, trimmed.length()); + return "***" + trimmed.substring(trimmed.length() - suffixLength); + } + + @Nullable + private static Future executeRequest( + @NonNull RequestSpec requestSpec, + @NonNull List apiKeys, + @NonNull Callback callback + ) { + List normalizedApiKeys = sanitizeApiKeys(apiKeys); + if (normalizedApiKeys.isEmpty()) { + postFailure(callback, "No Gemini API keys configured."); + return null; + } + + return executor.submit(() -> { + String lastError = "Unknown Gemini request failure."; + + for (int modelIndex = 0; modelIndex < GEMINI_MODELS.length; modelIndex++) { + String model = GEMINI_MODELS[modelIndex]; + + for (int apiKeyIndex = 0; apiKeyIndex < normalizedApiKeys.size(); apiKeyIndex++) { + String apiKey = normalizedApiKeys.get(apiKeyIndex); + AttemptState attempt = new AttemptState( + model, + modelIndex, + GEMINI_MODELS.length, + apiKey, + apiKeyIndex, + normalizedApiKeys.size() + ); + + AttemptResult result = performAttempt(requestSpec, attempt, callback); + switch (result.status) { + case SUCCESS: + assert result.resultText != null; + postSuccess(callback, result.resultText, attempt.model); + return; + + case RETRY: + lastError = result.errorMessage; + logRetry(attempt, normalizedApiKeys, modelIndex, result.errorMessage); + continue; + + case FAILURE: + postFailure(callback, result.errorMessage); + return; + + case CANCELLED: + postFailure(callback, "Operation cancelled."); + Thread.currentThread().interrupt(); + return; + } + } + } + + postFailure(callback, lastError); + }); + } + + @NonNull + private static AttemptResult performAttempt( + @NonNull RequestSpec requestSpec, + @NonNull AttemptState attempt, + @NonNull Callback callback + ) { + HttpURLConnection connection = null; + + try { + Logger.printInfo(() -> "GeminiUtils: Attempting " + describeAttempt(attempt)); + + String effectivePrompt = resolvePromptForModel(attempt.model, requestSpec.promptText, requestSpec.promptForGemini25); + if (requestSpec.promptText != null && !TextUtils.equals(effectivePrompt, requestSpec.promptText)) { + Logger.printDebug(() -> "GeminiUtils: Using model-specific prompt for " + describeAttempt(attempt) + "."); + } + + URL url = new URL(BASE_API_URL + attempt.model + (requestSpec.streaming ? STREAM_ACTION : GENERATE_ACTION) + attempt.apiKey); + connection = (HttpURLConnection) url.openConnection(); + configureConnection(connection, requestSpec.streaming); + + JSONObject requestBody = buildRequestBody(requestSpec, effectivePrompt, attempt.model); + writeRequestBody(connection, requestBody); + + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("Gemini task cancelled before reading response."); + } + + int responseCode = connection.getResponseCode(); + if (requestSpec.streaming) { + return processStreamingResponse(responseCode, connection, attempt, callback); + } + return processStandardResponse(responseCode, connection, requestSpec, attempt); + } catch (java.net.SocketTimeoutException e) { + Logger.printException(() -> "Gemini API request timed out (" + describeAttempt(attempt) + ")", e); + return AttemptResult.retry("Request timed out after " + (connection != null ? connection.getReadTimeout() / 1000 : "?") + " seconds."); + } catch (InterruptedException e) { + Logger.printInfo(() -> "Gemini task explicitly cancelled."); + Thread.currentThread().interrupt(); + return AttemptResult.cancelled(); + } catch (IOException e) { + Logger.printException(() -> "Gemini API request IO failed (" + describeAttempt(attempt) + ")", e); + return AttemptResult.retry(e.getMessage() != null ? "Network error: " + e.getMessage() : "Unknown network error"); + } catch (Exception e) { + Logger.printException(() -> "Gemini API request failed (" + describeAttempt(attempt) + ")", e); + return AttemptResult.retry(e.getMessage() != null ? e.getMessage() : "Unknown error during request setup"); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @NonNull + private static AttemptResult processStandardResponse( + int responseCode, + @NonNull HttpURLConnection connection, + @NonNull RequestSpec requestSpec, + @NonNull AttemptState attempt + ) throws IOException, JSONException, InterruptedException { + String responseString = readResponseBody(responseCode, connection); + + if (responseCode != HttpURLConnection.HTTP_OK) { + String errorMessage = parseErrorMessage(responseCode, responseString); + Logger.printException(() -> "Gemini API Error (" + describeAttempt(attempt) + ", HTTP " + responseCode + "). Response: " + responseString); + return AttemptResult.retry(errorMessage); + } + + Logger.printInfo(() -> "GeminiUtils: Model succeeded: " + describeAttempt(attempt) + " (HTTP 200)"); + JSONObject jsonResponse = new JSONObject(responseString); + + if (!jsonResponse.has("candidates") || jsonResponse.getJSONArray("candidates").length() == 0) { + String blockReason = extractBlockReason(jsonResponse); + String finalError = blockReason != null + ? "Content blocked: " + blockReason + : "API response missing valid candidates. Full Response: " + responseString.substring(0, Math.min(responseString.length(), 500)) + "..."; + Logger.printException(() -> "Gemini API Error (" + describeAttempt(attempt) + "): " + finalError); + return AttemptResult.retry(finalError); + } + + String resultText = extractTextFromResponse(jsonResponse); + String finalResult = requestSpec.expectJsonResponse ? sanitizeJsonOutput(resultText) : resultText; + Logger.printDebug(() -> "Gemini RAW result received (" + describeAttempt(attempt) + "): " + finalResult.substring(0, Math.min(finalResult.length(), 300)) + "..."); + + if (requestSpec.expectJsonResponse) { + boolean looksLikeJson = finalResult.startsWith("[") || finalResult.startsWith("{"); + if (!looksLikeJson) { + Logger.printInfo(() -> "Gemini JSON translation result doesn't look like valid JSON! (" + describeAttempt(attempt) + ")"); + return AttemptResult.retry("Translation result format error. Expected JSON."); + } + } + + return AttemptResult.success(finalResult); + } + + @NonNull + private static AttemptResult processStreamingResponse( + int responseCode, + @NonNull HttpURLConnection connection, + @NonNull AttemptState attempt, + @NonNull Callback callback + ) { + boolean emittedPartial = false; + StringBuilder accumulatedText = new StringBuilder(); + String lastFailureMessage = "Stream ended before returning text."; + + try { + if (responseCode != HttpURLConnection.HTTP_OK) { + String responseString = readResponseBody(responseCode, connection); + String errorMessage = parseErrorMessage(responseCode, responseString); + Logger.printException(() -> "Gemini API Error (" + describeAttempt(attempt) + ", HTTP " + responseCode + "). Response: " + responseString); + return AttemptResult.retry(errorMessage); + } + + Logger.printInfo(() -> "GeminiUtils: Model succeeded: " + describeAttempt(attempt) + " (HTTP 200 stream)"); + try (BufferedReader reader = getBufferedReader(responseCode, connection)) { + String line; + StringBuilder eventData = new StringBuilder(); + + while ((line = reader.readLine()) != null) { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("Gemini task cancelled while reading streamed response."); + } + + if (line.isEmpty()) { + if (eventData.length() > 0) { + StreamEventResult eventResult = processStreamEvent(eventData.toString(), accumulatedText, attempt, callback); + emittedPartial |= eventResult.emittedPartial; + if (eventResult.failureMessage != null) { + lastFailureMessage = eventResult.failureMessage; + } + eventData.setLength(0); + } + continue; + } + + if (line.startsWith("data:")) { + eventData.append(line.substring(5).trim()); + } + } + + if (eventData.length() > 0) { + StreamEventResult eventResult = processStreamEvent(eventData.toString(), accumulatedText, attempt, callback); + emittedPartial |= eventResult.emittedPartial; + if (eventResult.failureMessage != null) { + lastFailureMessage = eventResult.failureMessage; + } + } + } + + if (accumulatedText.length() > 0) { + return AttemptResult.success(accumulatedText.toString()); + } + return AttemptResult.retry(lastFailureMessage); + } catch (InterruptedException e) { + Logger.printInfo(() -> "Gemini task explicitly cancelled."); + Thread.currentThread().interrupt(); + return AttemptResult.cancelled(); + } catch (IOException e) { + Logger.printException(() -> "Gemini streamed request IO failed (" + describeAttempt(attempt) + ")", e); + if (emittedPartial) { + return AttemptResult.failure(); + } + return AttemptResult.retry(e.getMessage() != null ? "Network error: " + e.getMessage() : "Unknown network error"); + } catch (JSONException e) { + Logger.printException(() -> "Gemini streamed response parsing failed (" + describeAttempt(attempt) + ")", e); + if (emittedPartial) { + return AttemptResult.failure(); + } + return AttemptResult.retry("Failed to parse streamed response."); + } + } + + @NonNull + private static StreamEventResult processStreamEvent( + @NonNull String eventData, + @NonNull StringBuilder accumulatedText, + @NonNull AttemptState attempt, + @NonNull Callback callback + ) throws JSONException { + if (eventData.isEmpty() || "[DONE]".equals(eventData)) { + return StreamEventResult.empty(); + } + + JSONObject jsonResponse = new JSONObject(eventData); + if (jsonResponse.has("error")) { + JSONObject errorObject = jsonResponse.getJSONObject("error"); + return StreamEventResult.failure(errorObject.optString("message", "Unknown Gemini stream error")); + } + + JSONArray candidates = jsonResponse.optJSONArray("candidates"); + if (candidates == null || candidates.length() == 0) { + String blockReason = extractBlockReason(jsonResponse); + return StreamEventResult.failure(blockReason != null ? "Content blocked: " + blockReason : "API response missing valid streamed candidates."); + } + + JSONObject firstCandidate = candidates.getJSONObject(0); + JSONObject content = firstCandidate.optJSONObject("content"); + StringBuilder deltaText = new StringBuilder(); + if (content != null) { + JSONArray parts = content.optJSONArray("parts"); + if (parts != null) { + for (int i = 0; i < parts.length(); i++) { + JSONObject part = parts.optJSONObject(i); + if (part != null) { + String text = part.optString("text", ""); + if (!text.isEmpty()) { + deltaText.append(text); + } + } + } + } + } + + if (deltaText.length() > 0) { + accumulatedText.append(deltaText); + postPartial(callback, deltaText.toString(), accumulatedText.toString(), attempt.model); + return StreamEventResult.partial(); + } + + if (firstCandidate.has("finishReason")) { + String finishReason = firstCandidate.optString("finishReason", ""); + if (!TextUtils.isEmpty(finishReason) && !"STOP".equals(finishReason) && !"MAX_TOKENS".equals(finishReason)) { + String blockReason = extractBlockReason(jsonResponse); + return StreamEventResult.failure(blockReason != null ? "Content blocked: " + blockReason : finishReason); + } + } + + return StreamEventResult.empty(); + } + + @NonNull + private static JSONObject buildRequestBody( + @NonNull RequestSpec requestSpec, + @Nullable String effectivePrompt, + @NonNull String model + ) throws JSONException { + JSONObject requestBody = new JSONObject(); + requestBody.put("contents", buildContentsArray(requestSpec, effectivePrompt)); + + JSONObject generationConfig = new JSONObject(); + JSONObject thinkingConfig; + if (model.startsWith("gemini-3")) { + thinkingConfig = new JSONObject() + .put("thinkingLevel", "minimal") + .put("includeThoughts", false); + } else { + thinkingConfig = new JSONObject().put("thinkingBudget", 0); + } + generationConfig.put("thinkingConfig", thinkingConfig); + requestBody.put("generationConfig", generationConfig); + return requestBody; + } + + @NonNull + private static JSONArray buildContentsArray( + @NonNull RequestSpec requestSpec, + @Nullable String effectivePrompt + ) throws JSONException { + JSONArray contentsArray = new JSONArray(); + + if (requestSpec.isChatRequest()) { + JSONObject contextContent = new JSONObject(); + contextContent.put("role", "user"); + JSONArray contextParts = new JSONArray(); + contextParts.put(new JSONObject().put("fileData", new JSONObject() + .put("mimeType", "video/mp4") + .put("fileUri", requestSpec.videoUrl))); + contextParts.put(new JSONObject().put("text", requestSpec.chatContextPrompt)); + contextContent.put("parts", contextParts); + contentsArray.put(contextContent); + + for (ChatMessage message : requestSpec.history) { + contentsArray.put(new JSONObject() + .put("role", message.role) + .put("parts", new JSONArray().put(new JSONObject().put("text", message.text)))); + } + return contentsArray; + } + + JSONObject content = new JSONObject(); + JSONArray partsArray = new JSONArray(); + + if (requestSpec.videoUrl != null) { + Logger.printDebug(() -> "GeminiUtils: Constructing payload WITH video part."); + partsArray.put(new JSONObject().put("fileData", new JSONObject() + .put("mimeType", "video/mp4") + .put("fileUri", requestSpec.videoUrl))); + } else { + Logger.printDebug(() -> "GeminiUtils: Constructing payload with ONLY text part."); + } + + if (!TextUtils.isEmpty(effectivePrompt)) { + partsArray.put(new JSONObject().put("text", effectivePrompt)); + } + + content.put("parts", partsArray); + contentsArray.put(content); + return contentsArray; + } + + private static void configureConnection(@NonNull HttpURLConnection connection, boolean streaming) throws IOException { + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json; utf-8"); + connection.setRequestProperty("Accept", streaming ? "text/event-stream" : "application/json"); + connection.setDoOutput(true); + connection.setConnectTimeout(30000); + connection.setReadTimeout(6000000); + } + + private static void writeRequestBody(@NonNull HttpURLConnection connection, @NonNull JSONObject requestBody) throws IOException { + String jsonInputString = requestBody.toString(); + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + } + + @NonNull + private static String readResponseBody(int responseCode, @NonNull HttpURLConnection connection) throws IOException, InterruptedException { + StringBuilder response = new StringBuilder(); + try (BufferedReader reader = getBufferedReader(responseCode, connection)) { + String responseLine; + while ((responseLine = reader.readLine()) != null) { + response.append(responseLine.trim()); + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("Gemini task cancelled while reading response."); + } + } + } + return response.toString(); + } + + private static void logRetry( + @NonNull AttemptState attempt, + @NonNull List apiKeys, + int modelIndex, + @NonNull String reason + ) { + boolean hasAnotherKey = attempt.apiKeyIndex + 1 < apiKeys.size(); + boolean hasAnotherModel = modelIndex + 1 < GEMINI_MODELS.length; + + if (!hasAnotherKey && !hasAnotherModel) { + Logger.printDebug(() -> "GeminiUtils: No fallback left after " + describeAttempt(attempt) + " (" + reason + ")."); + return; + } + + String nextAttemptDescription; + AttemptState nextAttempt; + if (hasAnotherKey) { + nextAttempt = new AttemptState( + attempt.model, + attempt.modelIndex, + attempt.modelCount, + apiKeys.get(attempt.apiKeyIndex + 1), + attempt.apiKeyIndex + 1, + apiKeys.size() + ); + } else { + nextAttempt = new AttemptState( + GEMINI_MODELS[modelIndex + 1], + modelIndex + 1, + GEMINI_MODELS.length, + apiKeys.get(0), + 0, + apiKeys.size() + ); + } + nextAttemptDescription = describeAttempt(nextAttempt); + + Logger.printDebug(() -> "GeminiUtils: " + describeAttempt(attempt) + " failed (" + reason + "). Falling back to " + nextAttemptDescription + "."); + } + + @NonNull + private static String parseErrorMessage(int responseCode, @NonNull String responseString) { + String errorMessage = "HTTP Error: " + responseCode; + String errorDetails = " - " + responseString.substring(0, Math.min(responseString.length(), 200)) + "..."; + try { + JSONObject errorResponse = new JSONObject(responseString); + if (errorResponse.has("error") && errorResponse.getJSONObject("error").has("message")) { + errorMessage = errorResponse.getJSONObject("error").getString("message"); + } else { + errorMessage += errorDetails; + } + } catch (Exception jsonEx) { + errorMessage += errorDetails; + } + return errorMessage; + } + + @NonNull + private static String extractTextFromResponse(@NonNull JSONObject jsonResponse) throws JSONException { + JSONArray parts = jsonResponse.getJSONArray("candidates") + .getJSONObject(0) + .getJSONObject("content") + .getJSONArray("parts"); + + StringBuilder result = new StringBuilder(); + for (int i = 0; i < parts.length(); i++) { + JSONObject part = parts.getJSONObject(i); + if (part.has("text")) { + result.append(part.getString("text")); + } + } + return result.toString(); + } + + @NonNull + private static List sanitizeApiKeys(@NonNull List apiKeys) { + LinkedHashSet sanitized = new LinkedHashSet<>(); + for (String apiKey : apiKeys) { + if (!TextUtils.isEmpty(apiKey)) { + String trimmed = apiKey.trim(); + if (!trimmed.isEmpty()) { + sanitized.add(trimmed); + } + } + } + return new ArrayList<>(sanitized); + } + + @NonNull + private static String describeAttempt(@NonNull AttemptState attempt) { + return "model [" + (attempt.modelIndex + 1) + "/" + attempt.modelCount + "] " + attempt.model + + ", key [" + (attempt.apiKeyIndex + 1) + "/" + attempt.apiKeyCount + "] " + maskApiKey(attempt.apiKey); + } + + private static void postSuccess(@NonNull Callback callback, @NonNull String result, @Nullable String model) { + mainThreadHandler.post(() -> callback.onSuccessWithModel(result, model)); + } + + private static void postFailure(@NonNull Callback callback, @NonNull String error) { + mainThreadHandler.post(() -> callback.onFailure(error)); + } + + private static void postPartial( + @NonNull Callback callback, + @NonNull String partialText, + @NonNull String accumulatedText, + @Nullable String model + ) { + mainThreadHandler.post(() -> callback.onPartial(partialText, accumulatedText, model)); + } + + @NonNull + private static String resolvePromptForModel( + @NonNull String model, + @Nullable String defaultPrompt, + @Nullable String promptForGemini25 + ) { + if (TextUtils.isEmpty(defaultPrompt)) { + return ""; + } if (model.startsWith("gemini-2.5") && !TextUtils.isEmpty(promptForGemini25)) { return promptForGemini25; } @@ -433,7 +730,10 @@ public class GeminiUtils { java.io.InputStream errorStream = connection.getErrorStream(); if (errorStream == null) { java.io.InputStream inputStream = null; - try {inputStream = connection.getInputStream();} catch (IOException ignored) {} + try { + inputStream = connection.getInputStream(); + } catch (IOException ignored) { + } if (inputStream != null) { Logger.printInfo(() -> "HTTP error " + responseCode + " but errorStream was null. Reading from inputStream instead."); reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); @@ -457,7 +757,6 @@ public class GeminiUtils { @Nullable private static String extractBlockReason(JSONObject jsonResponse) { try { - // Check top-level promptFeedback first (usually indicates input blocking) if (jsonResponse.has("promptFeedback")) { JSONObject promptFeedback = jsonResponse.getJSONObject("promptFeedback"); if (promptFeedback.has("blockReason")) { @@ -465,44 +764,49 @@ public class GeminiUtils { String details = promptFeedback.optString("blockReasonMessage", ""); if (details.isEmpty() && promptFeedback.has("safetyRatings")) { String safetyDetail = getSafetyBlockDetail(promptFeedback.getJSONArray("safetyRatings")); - if (safetyDetail != null) details = safetyDetail; + if (safetyDetail != null) { + details = safetyDetail; + } } return reason + (details.isEmpty() ? "" : ": " + details); } - // Even if no blockReason, check safety ratings in promptFeedback if (promptFeedback.has("safetyRatings")) { String safetyDetail = getSafetyBlockDetail(promptFeedback.getJSONArray("safetyRatings")); - if (safetyDetail != null) return "SAFETY (" + safetyDetail + ")"; + if (safetyDetail != null) { + return "SAFETY (" + safetyDetail + ")"; + } } } - // Check candidate information (usually indicates output blocking or other finish reasons) if (jsonResponse.has("candidates")) { JSONArray candidates = jsonResponse.getJSONArray("candidates"); if (candidates.length() > 0) { JSONObject firstCandidate = candidates.getJSONObject(0); - // Check finishReason first if (firstCandidate.has("finishReason")) { String finishReason = firstCandidate.getString("finishReason"); - // Only report non-standard finish reasons as errors/blocks if (!"STOP".equals(finishReason) && !"MAX_TOKENS".equals(finishReason)) { - // If safety related, try to get more details if ("SAFETY".equals(finishReason) && firstCandidate.has("safetyRatings")) { String safetyDetail = getSafetyBlockDetail(firstCandidate.getJSONArray("safetyRatings")); - if (safetyDetail != null) return "SAFETY (" + safetyDetail + ")"; + if (safetyDetail != null) { + return "SAFETY (" + safetyDetail + ")"; + } } - // Return the finish reason itself if not STOP or MAX_TOKENS return finishReason; } } - // If finishReason is STOP/MAX_TOKENS or missing, check safety ratings anyway if (firstCandidate.has("safetyRatings")) { String safetyDetail = getSafetyBlockDetail(firstCandidate.getJSONArray("safetyRatings")); - if (safetyDetail != null) return "SAFETY (" + safetyDetail + ")"; + if (safetyDetail != null) { + return "SAFETY (" + safetyDetail + ")"; + } } - } else {return "No candidates returned";} + } else { + return "No candidates returned"; + } } - } catch (JSONException e) {Logger.printException(() -> "Error extracting block reason", e);} + } catch (JSONException e) { + Logger.printException(() -> "Error extracting block reason", e); + } return null; } @@ -512,15 +816,14 @@ public class GeminiUtils { * Prioritizes explicitly blocked ratings, then HIGH probability, then MEDIUM probability. * * @param safetyRatings The JSONArray containing safety rating objects. - * @return A string detailing the most severe safety issue found (e.g., "HARM_CATEGORY_HARASSMENT - BLOCKED", - * "HARM_CATEGORY_HATE_SPEECH - HIGH"), or null if no ratings indicate MEDIUM, HIGH, or BLOCKED status. + * @return A string detailing the most severe safety issue found, or null if no concerning rating exists. * @throws JSONException If parsing the safety rating objects fails. */ @Nullable private static String getSafetyBlockDetail(JSONArray safetyRatings) throws JSONException { String mostSevereCategory = null; String mostSevereProbability = null; - int severityLevel = 0; // 0: OK, 1: Medium, 2: High, 3: Blocked + int severityLevel = 0; for (int i = 0; i < safetyRatings.length(); i++) { JSONObject rating = safetyRatings.getJSONObject(i); @@ -529,19 +832,27 @@ public class GeminiUtils { boolean blocked = rating.optBoolean("blocked", false); int currentSeverity = 0; - if (blocked) currentSeverity = 3; - else if (probability.endsWith("HIGH")) currentSeverity = 2; - else if (probability.equals("MEDIUM")) currentSeverity = 1; + if (blocked) { + currentSeverity = 3; + } else if (probability.endsWith("HIGH")) { + currentSeverity = 2; + } else if ("MEDIUM".equals(probability)) { + currentSeverity = 1; + } if (currentSeverity > severityLevel) { severityLevel = currentSeverity; mostSevereCategory = category; mostSevereProbability = blocked ? "BLOCKED" : probability; - if (blocked) break; // Found blocked, highest severity + if (blocked) { + break; + } } } - if (severityLevel >= 1) {return mostSevereCategory + " - " + mostSevereProbability;} + if (severityLevel >= 1) { + return mostSevereCategory + " - " + mostSevereProbability; + } return null; } @@ -563,7 +874,6 @@ public class GeminiUtils { Logger.printException(() -> "Failed to get language locale from settings, using system default.", e); } - // Fallback to system default locale try { Locale defaultLocale = Locale.getDefault(); if (!TextUtils.isEmpty(defaultLocale.getLanguage())) { @@ -573,7 +883,6 @@ public class GeminiUtils { Logger.printException(() -> "Failed to get system default language name, using 'English'.", e); } - // Absolute fallback return "English"; } @@ -581,7 +890,9 @@ public class GeminiUtils { * Cleans the Gemini response to remove potential Markdown formatting. */ private static String sanitizeJsonOutput(String text) { - if (text == null) return ""; + if (text == null) { + return ""; + } text = text.trim(); if (text.startsWith("```")) { @@ -593,26 +904,123 @@ public class GeminiUtils { return text.trim(); } + public record ChatMessage(@NonNull String role, @NonNull String text) { + } + + private record RequestSpec(@Nullable String videoUrl, @Nullable String promptText, + @Nullable String promptForGemini25, boolean streaming, + boolean expectJsonResponse, @Nullable String chatContextPrompt, + @NonNull List history) { + + @NonNull + static RequestSpec forPrompt( + @Nullable String videoUrl, + @NonNull String promptText, + @Nullable String promptForGemini25, + boolean streaming, + boolean expectJsonResponse + ) { + return new RequestSpec(videoUrl, promptText, promptForGemini25, streaming, expectJsonResponse, null, new ArrayList<>()); + } + + @NonNull + static RequestSpec forChat( + @NonNull String videoUrl, + @NonNull String summary, + @NonNull List history + ) { + String languageName = getLanguageName(); + String chatPrompt = "You are continuing a conversation about this video. Use the video itself and the summary below to answer follow-up questions in " + languageName + ". Use Markdown when it improves readability. If relevant, cite timestamps in [MM:SS] or [HH:MM:SS]. If an answer is not supported by the video, say so briefly.\n\nSummary:\n" + summary; + return new RequestSpec(videoUrl, null, null, true, false, chatPrompt, new ArrayList<>(history)); + } + + boolean isChatRequest() { + return chatContextPrompt != null; + } + } + + private record AttemptState(@NonNull String model, int modelIndex, int modelCount, + @NonNull String apiKey, int apiKeyIndex, int apiKeyCount) { + } + + private enum AttemptStatus { + SUCCESS, + RETRY, + FAILURE, + CANCELLED + } + + private record AttemptResult(@NonNull AttemptStatus status, @Nullable String resultText, + @NonNull String errorMessage) { + + @NonNull + private static AttemptResult success(@NonNull String resultText) { + return new AttemptResult(AttemptStatus.SUCCESS, resultText, ""); + } + + @NonNull + private static AttemptResult retry(@NonNull String errorMessage) { + return new AttemptResult(AttemptStatus.RETRY, null, errorMessage); + } + + @NonNull + private static AttemptResult failure() { + return new AttemptResult(AttemptStatus.FAILURE, null, "Stream interrupted after partial response."); + } + + @NonNull + private static AttemptResult cancelled() { + return new AttemptResult(AttemptStatus.CANCELLED, null, "Operation cancelled."); + } + } + + private record StreamEventResult(boolean emittedPartial, @Nullable String failureMessage) { + @NonNull + private static StreamEventResult partial() { + return new StreamEventResult(true, null); + } + + @NonNull + private static StreamEventResult empty() { + return new StreamEventResult(false, null); + } + + @NonNull + private static StreamEventResult failure(@NonNull String failureMessage) { + return new StreamEventResult(false, failureMessage); + } + } + /** - * Callback interface for Gemini API operations (summary, transcription). - * Defines methods to handle successful results or failures. + * Callback interface for Gemini API operations (summary, transcription, and chat). + * Defines methods to handle streamed chunks, successful results, or failures. */ public interface Callback { /** * Called when the Gemini API request completes successfully. * - * @param result The generated text (summary or transcription) from the API. + * @param result The generated text from the API. */ void onSuccess(String result); + /** + * Called when the Gemini API emits a streamed text chunk. + * + * @param partialText The newly received delta text. + * @param accumulatedText The full text accumulated so far. + * @param model The model currently producing the response. + */ + default void onPartial(String partialText, String accumulatedText, @Nullable String model) { + } + /** * Called when the Gemini API request completes successfully, with the model used. * Default implementation preserves backward compatibility by delegating to {@link #onSuccess(String)}. * - * @param result The generated text (summary or transcription) from the API. + * @param result The generated text from the API. * @param model The exact model that produced the result. */ - default void onSuccessWithModel(String result, String model) { + default void onSuccessWithModel(String result, @Nullable String model) { onSuccess(result); } @@ -623,4 +1031,10 @@ public class GeminiUtils { */ void onFailure(String error); } + + @NonNull + private static String getPrompt() { + String langName = getLanguageName(); + return "Write a spoiler-heavy summary of this video in " + langName + ". Focus on key events in chronological order. Use Markdown to make it readable, and mark important details with bold text. Output only the summary-do not include a preamble, greetings, or phrases like \"Here is the summary.\" If the video contains sponsored segments, exclude them entirely. Add a TL;DR at the beginning that gives only the most important information. Then provide the summary, separated from the TL;DR."; + } } diff --git a/patches/src/main/kotlin/app/morphe/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt b/patches/src/main/kotlin/app/morphe/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt index 65d7a8a92..ef134ec5f 100644 --- a/patches/src/main/kotlin/app/morphe/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt +++ b/patches/src/main/kotlin/app/morphe/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt @@ -170,8 +170,10 @@ val overlayButtonsPatch = resourcePatch( "drawable", "playlist_repeat_button.xml", "playlist_shuffle_button.xml", - "revanced_repeat_button.xml", + "revanced_gemini_copy.xml", + "revanced_gemini_send.xml", "revanced_mute_volume_button.xml", + "revanced_repeat_button.xml", "revanced_vot_button.xml", ) ) diff --git a/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_gemini_copy.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_gemini_copy.xml new file mode 100644 index 000000000..ac2c68c42 --- /dev/null +++ b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_gemini_copy.xml @@ -0,0 +1,10 @@ + + + diff --git a/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_gemini_send.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_gemini_send.xml new file mode 100644 index 000000000..62198cb72 --- /dev/null +++ b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_gemini_send.xml @@ -0,0 +1,10 @@ + + + diff --git a/patches/src/main/resources/youtube/settings/host/values/strings.xml b/patches/src/main/resources/youtube/settings/host/values/strings.xml index ec56fe3ae..caf26465d 100644 --- a/patches/src/main/resources/youtube/settings/host/values/strings.xml +++ b/patches/src/main/resources/youtube/settings/host/values/strings.xml @@ -636,6 +636,7 @@ Known issue: Sometimes the swipe overlay appears before switching to fullscreen Summarizing the video... Transcribing the video... Yandex: Getting subtitles... + Ask about this video Translating with Gemini...\n\n⚠️ This step may fail if Gemini ignores the instructions or hits the limit. Gemini Video Summary (Took %d seconds) @@ -645,6 +646,7 @@ Known issue: Sometimes the swipe overlay appears before switching to fullscreen Subtitles Subtitle parsed successfully Gemini Video Transcription + Send Displays the optimization dialog for GMSCore at each application startup. Show optimization dialog for GMSCore Gradient start and end positions from 0 to 1 separated by comma. @@ -1753,8 +1755,8 @@ AI-generated responses may contain inaccurate information. Tap here to get an API key." About Gemini Summarize - "Get API key at https://aistudio.google.com/apikey" - Gemini API key + "Enter one Gemini API key per line. Get API keys at https://aistudio.google.com/apikey" + Gemini API keys "Tap to summarize the video. Tap and hold to transcribe. Summarized and transcribed results are saved unless you closed the app or used Gemini on another video. Tap the button again to view results." diff --git a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml index ef19b9d22..38c79cec8 100644 --- a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml +++ b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml @@ -339,7 +339,7 @@