diff --git a/TMessagesProj/src/main/java/org/telegram/ui/Adapters/DialogsAdapter.java b/TMessagesProj/src/main/java/org/telegram/ui/Adapters/DialogsAdapter.java index 21daa55e..e3a36353 100644 --- a/TMessagesProj/src/main/java/org/telegram/ui/Adapters/DialogsAdapter.java +++ b/TMessagesProj/src/main/java/org/telegram/ui/Adapters/DialogsAdapter.java @@ -56,6 +56,7 @@ import org.telegram.ui.Cells.DialogMeUrlCell; import org.telegram.ui.Cells.DialogsEmptyCell; import org.telegram.ui.Cells.DialogsHintCell; import org.telegram.ui.Cells.DialogsRequestedEmptyCell; +import org.telegram.ui.Cells.FoxAccountInfoCell; import org.telegram.ui.Cells.GraySectionCell; import org.telegram.ui.Cells.HeaderCell; import org.telegram.ui.Cells.ProfileSearchCell; @@ -82,6 +83,8 @@ import java.util.Collections; import java.util.HashSet; import java.util.Objects; +import tw.nekomimi.nekogram.helpers.SponsorHelper; + public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements DialogCell.DialogCellDelegate { public final static int VIEW_TYPE_DIALOG = 0, VIEW_TYPE_FLICKER = 1, @@ -104,7 +107,8 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements VIEW_TYPE_STORIES = 18, VIEW_TYPE_ARCHIVE_FULLSCREEN = 19, VIEW_TYPE_GRAY_SECTION = 20, - VIEW_TYPE_FORWARD_TO_STORIES_CELL = 21; + VIEW_TYPE_FORWARD_TO_STORIES_CELL = 21, + VIEW_TYPE_FOX_ACCOUNT_INFO = 22; private Context mContext; private ArchiveHintCell archiveHintCell; @@ -172,6 +176,18 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements isReordering = reordering; } + private boolean shouldShowFoxAccountInfo() { + return dialogsType == DialogsActivity.DIALOGS_TYPE_DEFAULT + && folderId == 0 + && !isOnlySelect + && !isTransitionSupport + && !collapsedView + && requestPeerType == null + && (parentFragment == null || !parentFragment.isReplyTo) + && getCurrentFilter() == null + && SponsorHelper.isCurrentUserLinked(); + } + public int fixPosition(int position) { if (hasChatlistHint) { position--; @@ -328,6 +344,8 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements this.emptyType = viewTypeEmpty; if (viewTypeEmpty == VIEW_TYPE_LAST_EMPTY) { stableId = 1; + } else if (viewTypeEmpty == VIEW_TYPE_FOX_ACCOUNT_INFO) { + stableId = 7; } else { if (viewType == VIEW_TYPE_ARCHIVE_FULLSCREEN) { stableId = 5; @@ -574,7 +592,7 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements return viewType != VIEW_TYPE_FLICKER && viewType != VIEW_TYPE_EMPTY && viewType != VIEW_TYPE_DIVIDER && viewType != VIEW_TYPE_SHADOW && viewType != VIEW_TYPE_HEADER && viewType != VIEW_TYPE_LAST_EMPTY && viewType != VIEW_TYPE_NEW_CHAT_HINT && viewType != VIEW_TYPE_CONTACTS_FLICKER && - viewType != VIEW_TYPE_REQUIREMENTS && viewType != VIEW_TYPE_REQUIRED_EMPTY && viewType != VIEW_TYPE_STORIES && viewType != VIEW_TYPE_ARCHIVE_FULLSCREEN && viewType != VIEW_TYPE_GRAY_SECTION; + viewType != VIEW_TYPE_REQUIREMENTS && viewType != VIEW_TYPE_REQUIRED_EMPTY && viewType != VIEW_TYPE_STORIES && viewType != VIEW_TYPE_ARCHIVE_FULLSCREEN && viewType != VIEW_TYPE_GRAY_SECTION && viewType != VIEW_TYPE_FOX_ACCOUNT_INFO; } @Override @@ -765,6 +783,9 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements case VIEW_TYPE_FOLDER_UPDATE_HINT: view = new DialogsHintCell(mContext); break; + case VIEW_TYPE_FOX_ACCOUNT_INFO: + view = new FoxAccountInfoCell(mContext); + break; case VIEW_TYPE_STORIES: { view = new View(mContext) { @Override @@ -1083,6 +1104,10 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements } break; } + case VIEW_TYPE_FOX_ACCOUNT_INFO: { + ((FoxAccountInfoCell) holder.itemView).update(); + break; + } } if (i >= dialogsCount + 1) { holder.itemView.setAlpha(1f); @@ -1475,6 +1500,10 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements return; } + if (shouldShowFoxAccountInfo()) { + itemInternals.add(new ItemInternal(VIEW_TYPE_FOX_ACCOUNT_INFO)); + } + if (!hasHints && dialogsType == 0 && folderId == 0 && messagesController.isDialogsEndReached(folderId) && !forceUpdatingContacts) { if (messagesController.getAllFoldersDialogsCount() <= 10 && ContactsController.getInstance(currentAccount).doneLoadingContacts && !ContactsController.getInstance(currentAccount).contacts.isEmpty()) { onlineContacts = new ArrayList<>(ContactsController.getInstance(currentAccount).contacts); diff --git a/TMessagesProj/src/main/java/org/telegram/ui/Cells/FoxAccountInfoCell.java b/TMessagesProj/src/main/java/org/telegram/ui/Cells/FoxAccountInfoCell.java new file mode 100644 index 00000000..5ba4925f --- /dev/null +++ b/TMessagesProj/src/main/java/org/telegram/ui/Cells/FoxAccountInfoCell.java @@ -0,0 +1,85 @@ +package org.telegram.ui.Cells; + +import android.content.Context; +import android.util.TypedValue; +import android.view.Gravity; +import android.widget.FrameLayout; +import android.widget.TextView; + +import org.telegram.messenger.AndroidUtilities; +import org.telegram.messenger.LocaleController; +import org.telegram.messenger.R; +import org.telegram.messenger.UserConfig; +import org.telegram.ui.ActionBar.Theme; +import org.telegram.ui.Components.LayoutHelper; + +import java.util.Locale; + +import tw.nekomimi.nekogram.helpers.SponsorHelper; + +/** + * A slim, non-clickable info row shown at the very top of the chats list for + * users who linked their Telegram account to GhostCloud/FoxCloud. Shows the + * account balance on the left and the subscription expiry on the right. + */ +public class FoxAccountInfoCell extends FrameLayout { + + private final TextView balanceTextView; + private final TextView subTextView; + + public FoxAccountInfoCell(Context context) { + super(context); + + balanceTextView = new TextView(context); + balanceTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13); + balanceTextView.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteGrayText2)); + balanceTextView.setGravity(Gravity.LEFT | Gravity.CENTER_VERTICAL); + balanceTextView.setSingleLine(); + addView(balanceTextView, LayoutHelper.createFrame(LayoutHelper.WRAP_CONTENT, LayoutHelper.MATCH_PARENT, + Gravity.LEFT | Gravity.CENTER_VERTICAL, 16, 0, 8, 0)); + + subTextView = new TextView(context); + subTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13); + subTextView.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteGrayText2)); + subTextView.setGravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL); + subTextView.setSingleLine(); + addView(subTextView, LayoutHelper.createFrame(LayoutHelper.WRAP_CONTENT, LayoutHelper.MATCH_PARENT, + Gravity.RIGHT | Gravity.CENTER_VERTICAL, 8, 0, 16, 0)); + + update(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(AndroidUtilities.dp(30), MeasureSpec.EXACTLY)); + } + + public void update() { + var user = UserConfig.getInstance(UserConfig.selectedAccount).getCurrentUser(); + long tgId = user != null ? user.id : 0; + + long kopecks = SponsorHelper.getBalanceKopecks(tgId); + double rubles = kopecks / 100.0; + String balanceStr; + if (rubles == Math.floor(rubles)) { + balanceStr = String.format(Locale.getDefault(), "%,d \u20bd", (long) rubles); + } else { + balanceStr = String.format(Locale.getDefault(), "%,.2f \u20bd", rubles); + } + balanceTextView.setText(LocaleController.formatString(R.string.FoxAccountBalance, balanceStr)); + + String expireAt = SponsorHelper.getExpireAt(tgId); + long expireMs = SponsorHelper.parseExpireMillis(expireAt); + if (expireMs <= 0) { + subTextView.setText(LocaleController.getString(R.string.FoxAccountSubNone)); + } else { + long now = System.currentTimeMillis(); + if (expireMs <= now) { + subTextView.setText(LocaleController.getString(R.string.FoxAccountSubExpired)); + } else { + long daysLeft = (long) Math.ceil((expireMs - now) / (24.0 * 60 * 60 * 1000)); + subTextView.setText(LocaleController.formatString(R.string.FoxAccountDaysLeft, (int) daysLeft)); + } + } + } +} diff --git a/TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java b/TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java index bbf1e4db..8f9031d2 100644 --- a/TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java +++ b/TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java @@ -2865,6 +2865,7 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter. if (!onlySelect) { globalObserversGroup.add(NotificationCenter.closeSearchByActiveAction); globalObserversGroup.add(NotificationCenter.proxySettingsChanged); + globalObserversGroup.add(NotificationCenter.sponsorStatusUpdated); observersGroup.add(NotificationCenter.filterSettingsUpdated); observersGroup.add(NotificationCenter.dialogsUnreadCounterChanged); } @@ -6992,6 +6993,9 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter. @Override public void onResume() { super.onResume(); + if (!onlySelect && folderId == 0) { + tw.nekomimi.nekogram.helpers.SponsorHelper.refresh(false); + } if (dialogStoriesCell != null) { dialogStoriesCell.onResume(); } @@ -10380,6 +10384,16 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter. @SuppressWarnings("unchecked") @Override public void didReceivedNotification(int id, int account, Object... args) { + if (id == NotificationCenter.sponsorStatusUpdated) { + if (viewPages != null) { + for (int a = 0; a < viewPages.length; a++) { + if (viewPages[a] != null && viewPages[a].dialogsAdapter != null) { + viewPages[a].dialogsAdapter.notifyDataSetChanged(); + } + } + } + return; + } if (id == NotificationCenter.dialogsNeedReload) { if (viewPages == null || dialogsListFrozen) { return; diff --git a/TMessagesProj/src/main/java/tw/nekomimi/nekogram/helpers/SponsorHelper.java b/TMessagesProj/src/main/java/tw/nekomimi/nekogram/helpers/SponsorHelper.java index 7695108f..db03bfe8 100644 --- a/TMessagesProj/src/main/java/tw/nekomimi/nekogram/helpers/SponsorHelper.java +++ b/TMessagesProj/src/main/java/tw/nekomimi/nekogram/helpers/SponsorHelper.java @@ -61,6 +61,73 @@ public final class SponsorHelper { return user != null && isSponsor(user.id); } + /** + * Whether the given Telegram id is linked to a FoxCloud/GhostCloud account. + * Only meaningful after at least one successful {@link #refresh(boolean)}. + */ + public static boolean isLinked(long telegramId) { + if (telegramId == 0) { + return false; + } + return prefs().getBoolean("linked_" + telegramId, false); + } + + public static boolean isCurrentUserLinked() { + var user = UserConfig.getInstance(UserConfig.selectedAccount).getCurrentUser(); + return user != null && isLinked(user.id); + } + + /** Cached account balance in kopecks (1/100 of a ruble). */ + public static long getBalanceKopecks(long telegramId) { + if (telegramId == 0) { + return 0; + } + return prefs().getLong("balance_" + telegramId, 0); + } + + /** + * Cached subscription expiry, as the raw string returned by the backend + * ({@code "yyyy-MM-dd HH:mm:ss"}), or empty when there is no subscription. + */ + public static String getExpireAt(long telegramId) { + if (telegramId == 0) { + return ""; + } + return prefs().getString("expire_" + telegramId, ""); + } + + /** Cached plan name, or empty. */ + public static String getPlan(long telegramId) { + if (telegramId == 0) { + return ""; + } + return prefs().getString("plan_" + telegramId, ""); + } + + /** + * Parse the backend expiry string ({@code "yyyy-MM-dd HH:mm:ss"}, treated as + * UTC) into epoch millis, or 0 if absent/unparseable. + */ + public static long parseExpireMillis(String expireAt) { + if (expireAt == null || expireAt.isEmpty()) { + return 0; + } + String s = expireAt.trim(); + String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"}; + for (String pattern : patterns) { + try { + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat(pattern, java.util.Locale.US); + fmt.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + java.util.Date d = fmt.parse(s); + if (d != null) { + return d.getTime(); + } + } catch (Exception ignore) { + } + } + return 0; + } + /** * Kick off a background refresh of the sponsor status for the current * account. Safe to call repeatedly: it self-throttles via {@link #TTL_MS} @@ -124,7 +191,12 @@ public final class SponsorHelper { return; } boolean sponsor = json.optBoolean("is_sponsor", false); - store(telegramId, sponsor); + boolean linked = json.optBoolean("telegram_linked", false); + // balance is sent in kopecks; the client divides by 100 for display. + long balance = json.optLong("balance", 0); + String expireAt = json.isNull("expire_at") ? "" : json.optString("expire_at", ""); + String plan = json.isNull("plan") ? "" : json.optString("plan", ""); + store(telegramId, sponsor, linked, balance, expireAt, plan); } catch (Exception e) { FileLog.e(e); } finally { @@ -134,13 +206,29 @@ public final class SponsorHelper { } } - private static void store(long telegramId, boolean sponsor) { - boolean previous = prefs().getBoolean("sponsor_" + telegramId, false); - prefs().edit() + private static void store(long telegramId, boolean sponsor, boolean linked, long balance, String expireAt, String plan) { + SharedPreferences p = prefs(); + boolean previousSponsor = p.getBoolean("sponsor_" + telegramId, false); + boolean previousLinked = p.getBoolean("linked_" + telegramId, false); + long previousBalance = p.getLong("balance_" + telegramId, 0); + String previousExpire = p.getString("expire_" + telegramId, ""); + String previousPlan = p.getString("plan_" + telegramId, ""); + + p.edit() .putBoolean("sponsor_" + telegramId, sponsor) + .putBoolean("linked_" + telegramId, linked) + .putLong("balance_" + telegramId, balance) + .putString("expire_" + telegramId, expireAt != null ? expireAt : "") + .putString("plan_" + telegramId, plan != null ? plan : "") .putLong("checked_" + telegramId, System.currentTimeMillis()) .apply(); - if (previous != sponsor) { + + boolean changed = previousSponsor != sponsor + || previousLinked != linked + || previousBalance != balance + || !previousExpire.equals(expireAt != null ? expireAt : "") + || !previousPlan.equals(plan != null ? plan : ""); + if (changed) { AndroidUtilities.runOnUIThread(() -> NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.sponsorStatusUpdated)); } @@ -149,7 +237,6 @@ public final class SponsorHelper { // ───────────────────────────────────────────────────────────── // Top sponsors leaderboard // ───────────────────────────────────────────────────────────── - public static class Sponsor { public int rank; public String name; diff --git a/TMessagesProj/src/main/res/values-ru/strings_neko.xml b/TMessagesProj/src/main/res/values-ru/strings_neko.xml index 164d62b1..b0c7be17 100644 --- a/TMessagesProj/src/main/res/values-ru/strings_neko.xml +++ b/TMessagesProj/src/main/res/values-ru/strings_neko.xml @@ -331,4 +331,9 @@ Те, кто поддерживает проект Пока нет спонсоров Не удалось загрузить список + Баланс: %1$s + Подписка: %1$s + Подписка истекла + Нет подписки + осталось %1$d дн. diff --git a/TMessagesProj/src/main/res/values/strings_neko.xml b/TMessagesProj/src/main/res/values/strings_neko.xml index bd49058f..df91974e 100644 --- a/TMessagesProj/src/main/res/values/strings_neko.xml +++ b/TMessagesProj/src/main/res/values/strings_neko.xml @@ -157,6 +157,11 @@ People who support the project No sponsors yet Failed to load sponsors + Balance: %1$s + Subscription: %1$s + Subscription expired + No subscription + %1$d d. left Downloading update... A notification will be shown when the update completes. The app will relaunch when the update completes.