Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ fastlane/*.json
vendor/bundle/
.bundle/
Gemfile.lockfastlane/node_modules/

# Documentation
.doc/
89 changes: 89 additions & 0 deletions app/src/main/java/me/ghui/v2er/module/home/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import me.ghui.v2er.util.Utils;
import me.ghui.v2er.util.ViewUtils;
import me.ghui.v2er.util.FontSizeUtil;
import me.ghui.v2er.util.VshareVersionChecker;
import me.ghui.v2er.widget.BaseToolBar;
import me.ghui.v2er.widget.CSlidingTabLayout;
import me.ghui.v2er.widget.FollowProgressBtn;
Expand Down Expand Up @@ -99,6 +100,10 @@ public class MainActivity extends BaseActivity implements View.OnClickListener,
private SwitchCompat mNightSwitch;
private HomeFilterMenu mFilterMenu;
private boolean isAppbarExpanded = true;
private View mVshareBadge;
private VshareVersionChecker mVshareVersionChecker;
private android.graphics.drawable.Drawable mOriginalNavIcon;
private android.graphics.drawable.LayerDrawable mNavIconWithBadge;

@Override
protected int attachLayoutRes() {
Expand Down Expand Up @@ -132,6 +137,9 @@ protected void configToolBar() {
mDrawerLayout.openDrawer(Gravity.START);
}
});

// Initialize toolbar badge support for hamburger icon
setupNavigationIconBadge();
mToolbar.setOnMenuItemClickListener(item -> {
if (item.getItemId() == R.id.action_search) {
pushFragment(SearchFragment.newInstance());
Expand All @@ -140,6 +148,33 @@ protected void configToolBar() {
});
}

private void setupNavigationIconBadge() {
// Save the original navigation icon
mOriginalNavIcon = getDrawable(R.drawable.nav).mutate();
mOriginalNavIcon.setTint(Theme.getColor(R.attr.icon_tint_color, this));

// Create a red dot drawable for the badge
android.graphics.drawable.ShapeDrawable badge = new android.graphics.drawable.ShapeDrawable(
new android.graphics.drawable.shapes.OvalShape());
badge.getPaint().setColor(0xFFF44336); // Red color
badge.setIntrinsicHeight(ScaleUtils.dp(8));
badge.setIntrinsicWidth(ScaleUtils.dp(8));

// Create a LayerDrawable with the navigation icon and badge
android.graphics.drawable.Drawable[] layers = new android.graphics.drawable.Drawable[2];
layers[0] = mOriginalNavIcon;
layers[1] = badge;

mNavIconWithBadge = new android.graphics.drawable.LayerDrawable(layers);

// Position the badge at the top-right corner of the icon
// Navigation icon is 24dp, badge is 8dp
mNavIconWithBadge.setLayerSize(1, ScaleUtils.dp(8), ScaleUtils.dp(8));
mNavIconWithBadge.setLayerGravity(1, Gravity.TOP | Gravity.END);
mNavIconWithBadge.setLayerInsetEnd(1, -ScaleUtils.dp(4)); // Overlap with icon edge
mNavIconWithBadge.setLayerInsetTop(1, -ScaleUtils.dp(4)); // Overlap with icon edge
Comment on lines +170 to +175
Copy link

Copilot AI Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Multiple hard-coded dp values and negative insets are brittle; consider computing offsets based on intrinsic sizes (icon + badge) or centralizing these values as named constants to ease future adjustments and ensure compatibility across densities and API levels.

Copilot uses AI. Check for mistakes.
}

@Override
public boolean onToolbarDoubleTaped() {
View rootView = getCurrentFragment().getView();
Expand Down Expand Up @@ -218,6 +253,11 @@ protected void init() {
if (UserUtils.notLoginAndProcessToLogin(false, getContext())) return true;
Navigator.from(getContext()).to(CreateTopicActivity.class).start();
break;
case R.id.vshare_nav_item:
Utils.openInBrowser("https://v2er.app/vshare", getContext());
mVshareVersionChecker.markAsViewed();
updateVshareBadge();
break;
case R.id.day_night_item:
onNightMenuItemClicked(DarkModelUtils.isDarkMode());
break;
Expand Down Expand Up @@ -277,6 +317,7 @@ public void onStateChanged(AppBarLayout appBarLayout, AppBarStateChangeListener.
mSlidingTabLayout.setOnTabSelectListener(this);
configNewsTabTitle();
initCheckIn();
initVshareVersionChecker();

int index = getIntent().getIntExtra(TAB_INDEX, 0);
mSlidingTabLayout.setCurrentTab(index);
Expand Down Expand Up @@ -329,6 +370,54 @@ private void initCheckIn() {
mCheckInPresenter.start();
}

private void initVshareVersionChecker() {
// Initialize the version checker
mVshareVersionChecker = new VshareVersionChecker(getContext());

// Delay to ensure menu is fully initialized
mNavigationView.post(() -> {
// Find vshare menu item and get the badge view
MenuItem vshareItem = mNavigationView.getMenu().findItem(R.id.vshare_nav_item);
if (vshareItem != null && vshareItem.getActionView() != null) {
mVshareBadge = vshareItem.getActionView().findViewById(R.id.vshare_badge_dot);
L.d("Vshare badge view found: " + (mVshareBadge != null));
} else {
L.e("Vshare menu item or action view is null");
}

// Check for version updates
mVshareVersionChecker.checkForUpdate()
.subscribe(hasUpdate -> {
L.d("Vshare version check result: hasUpdate=" + hasUpdate);
updateVshareBadge(hasUpdate);
}, throwable -> {
// Log errors for debugging
L.e("VshareVersionChecker error: " + throwable.getMessage());
throwable.printStackTrace();
});
});
}

private void updateVshareBadge() {
updateVshareBadge(false);
}

private void updateVshareBadge(boolean show) {
L.d("Setting vshare badge visibility: " + (show ? "VISIBLE" : "GONE"));

// Update menu badge
if (mVshareBadge != null) {
mVshareBadge.setVisibility(show ? View.VISIBLE : View.GONE);
} else {
L.e("Cannot update menu badge: mVshareBadge is null");
}

// Update toolbar navigation icon with badge
if (mNavIconWithBadge != null && mOriginalNavIcon != null) {
mToolbar.setNavigationIcon(show ? mNavIconWithBadge : mOriginalNavIcon);
}
}

private void applyFontSizeToNavigationMenu() {
// Apply font size based on preference
// This is better handled by setting text appearance in styles
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/me/ghui/v2er/network/APIs.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import me.ghui.v2er.network.bean.TopicStarInfo;
import me.ghui.v2er.network.bean.UserInfo;
import me.ghui.v2er.network.bean.UserPageInfo;
import me.ghui.v2er.network.bean.VshareVersionInfo;
import me.ghui.v2er.util.RefererUtils;
import okhttp3.ResponseBody;
import retrofit2.Response;
Expand Down Expand Up @@ -62,6 +63,10 @@ public interface APIs {
@GET("/api/members/show.json")
Observable<UserInfo> userInfo(@Query("username") String username);

@Json
@GET("https://v2er.app/api/vshare-version.json")
Observable<VshareVersionInfo> getVshareVersion();

@Json
@GET("https://www.sov2ex.com/api/search")
Observable<SoV2EXSearchResultInfo> search(@Query("q") String keyword, @Query("from") int from, @Query("sort") String sortWay);
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/me/ghui/v2er/network/bean/VshareVersionInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package me.ghui.v2er.network.bean;

import com.google.gson.annotations.SerializedName;

/**
* Vshare page version information
* Used to check if the vshare page content has been updated
*/
public class VshareVersionInfo extends BaseInfo {
@SerializedName("version")
private int version;

@SerializedName("lastUpdated")
private String lastUpdated;

public VshareVersionInfo() {
}

public int getVersion() {
return version;
}

public void setVersion(int version) {
this.version = version;
}

public String getLastUpdated() {
return lastUpdated;
}

public void setLastUpdated(String lastUpdated) {
this.lastUpdated = lastUpdated;
}

@Override
public boolean isValid() {
return version > 0;
}
}
121 changes: 121 additions & 0 deletions app/src/main/java/me/ghui/v2er/util/VshareVersionChecker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package me.ghui.v2er.util;

import android.content.Context;
import android.content.SharedPreferences;

import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import me.ghui.v2er.network.APIService;
import me.ghui.v2er.network.bean.VshareVersionInfo;

/**
* Checks for vshare page version updates
* Queries the API every 24 hours to check if the vshare page has been updated
*/
public class VshareVersionChecker {
private static final String PREFS_NAME = "vshare_version";
private static final String KEY_LAST_VERSION = "last_version";
private static final String KEY_LAST_CHECK_TIME = "last_check_time";
private static final long CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds

private final Context context;
private final SharedPreferences prefs;

public VshareVersionChecker(Context context) {
this.context = context.getApplicationContext();
this.prefs = this.context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}

/**
* Checks if there's a new version available
* Only performs network request if 24 hours have passed since last check
*
* @return Observable<Boolean> - true if there's an update, false otherwise
*/
public Observable<Boolean> checkForUpdate() {
long lastCheckTime = prefs.getLong(KEY_LAST_CHECK_TIME, 0);
long currentTime = System.currentTimeMillis();

// If less than 24 hours since last check, return cached result
if (currentTime - lastCheckTime < CHECK_INTERVAL) {
return Observable.just(hasUpdate());
}

// Perform network request
return APIService.get()
.getVshareVersion()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map(versionInfo -> {
if (versionInfo != null && versionInfo.isValid()) {
int serverVersion = versionInfo.getVersion();
int localVersion = prefs.getInt(KEY_LAST_VERSION, 0);

// Update last check time
prefs.edit()
.putLong(KEY_LAST_CHECK_TIME, currentTime)
.apply();

// If server version is newer, return true
return serverVersion > localVersion;
Comment on lines +60 to +61
Copy link

Copilot AI Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server version is never persisted here when a newer version is detected, so subsequent checks within 24h fall back to hasUpdate() which always returns false, causing the badge to disappear incorrectly before the user views the content. Persist the serverVersion (e.g. putInt(KEY_LAST_VERSION, serverVersion)) or store a separate 'latest_server_version' and compare it to a 'viewed_version'.

Suggested change
// If server version is newer, return true
return serverVersion > localVersion;
// If server version is newer, persist it and return true
if (serverVersion > localVersion) {
prefs.edit()
.putInt(KEY_LAST_VERSION, serverVersion)
.apply();
return true;
}
return false;

Copilot uses AI. Check for mistakes.
}
return false;
})
.onErrorReturn(throwable -> {
// On error, return cached result
return hasUpdate();
});
}

/**
* Marks the current version as viewed by the user
* This should be called when the user clicks on the vshare menu item
*/
public void markAsViewed() {
APIService.get()
.getVshareVersion()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(versionInfo -> {
if (versionInfo != null && versionInfo.isValid()) {
prefs.edit()
.putInt(KEY_LAST_VERSION, versionInfo.getVersion())
.apply();
}
}, throwable -> {
// Silently ignore errors
});
Comment on lines +75 to +88
Copy link

Copilot AI Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] markAsViewed() performs an extra network request solely to record a version the app already had when showing the badge. Cache the server version during checkForUpdate() and write it directly here (or pass it in) to avoid an unnecessary call.

Copilot uses AI. Check for mistakes.
}

/**
* Checks if there's an update based on cached data
* Does not perform network request
*
* @return true if there's an update, false otherwise
*/
private boolean hasUpdate() {
int lastVersion = prefs.getInt(KEY_LAST_VERSION, 0);
// If we haven't checked yet, assume no update
if (lastVersion == 0) {
return false;
}
// This would need server version to compare, but we're using cached data
// In practice, this will be updated by checkForUpdate()
return false;
}
Comment on lines +97 to +106
Copy link

Copilot AI Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasUpdate() always returns false, making the cached path (within 24h) ineffective and wiping any pending badge state. Either cache the last known server version separately and compare it to a stored viewed version, or persist a boolean flag indicating an unseen update.

Copilot uses AI. Check for mistakes.

/**
* Forces a check for updates regardless of the 24-hour interval
*
* @return Observable<Boolean> - true if there's an update, false otherwise
*/
public Observable<Boolean> forceCheckForUpdate() {
// Reset last check time to force an update
prefs.edit()
.putLong(KEY_LAST_CHECK_TIME, 0)
.apply();

return checkForUpdate();
}
}
Loading
Loading