Skip to content

Commit

Permalink
Merge pull request #877 from AzureAaron/update-notification
Browse files Browse the repository at this point in the history
Update Notifications
  • Loading branch information
AzureAaron authored Aug 18, 2024
2 parents 9fc533c + 3270ccf commit 996ff07
Show file tree
Hide file tree
Showing 5 changed files with 310 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/main/java/de/hysky/skyblocker/SkyblockerMod.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public void onInitializeClient() {
SkyblockerScreen.initClass();
ProfileViewerScreen.initClass();
Tips.init();
UpdateNotifications.init();
NEURepoManager.init();
//ImageRepoLoader.init();
ItemRepository.init();
Expand Down
215 changes: 215 additions & 0 deletions src/main/java/de/hysky/skyblocker/UpdateNotifications.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package de.hysky.skyblocker;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;

import org.jetbrains.annotations.VisibleForTesting;
import org.slf4j.Logger;

import com.google.gson.JsonParser;
import com.mojang.logging.LogUtils;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.JsonOps;
import com.mojang.serialization.codecs.RecordCodecBuilder;

import de.hysky.skyblocker.events.SkyblockEvents;
import de.hysky.skyblocker.utils.Constants;
import de.hysky.skyblocker.utils.Http;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.loader.api.SemanticVersion;
import net.fabricmc.loader.api.Version;
import net.fabricmc.loader.api.VersionParsingException;
import net.minecraft.SharedConstants;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.resource.language.I18n;
import net.minecraft.client.toast.SystemToast;
import net.minecraft.text.ClickEvent;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
import net.minecraft.util.StringIdentifiable;

public class UpdateNotifications {
private static final Logger LOGGER = LogUtils.getLogger();
private static final MinecraftClient CLIENT = MinecraftClient.getInstance();
private static final String BASE_URL = "https://api.modrinth.com/v2/project/y6DuFGwJ/version?loaders=[%22fabric%22]&game_versions=";
private static final Version MOD_VERSION = SkyblockerMod.SKYBLOCKER_MOD.getMetadata().getVersion();
private static final String MC_VERSION = SharedConstants.getGameVersion().getId();
private static final Path CONFIG_PATH = SkyblockerMod.CONFIG_DIR.resolve("update_notifications.json");
@VisibleForTesting
protected static final Comparator<Version> COMPARATOR = Version::compareTo;
@VisibleForTesting
protected static final Codec<SemanticVersion> SEM_VER_CODEC = Codec.STRING.comapFlatMap(UpdateNotifications::parseVersion, SemanticVersion::toString);
private static final SystemToast.Type TOAST_TYPE = new SystemToast.Type(10000L);

public static Config config = Config.DEFAULT;
private static boolean sentUpdateNotification;

static void init() {
ClientLifecycleEvents.CLIENT_STARTED.register(client -> loadConfig());
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> saveConfig());
SkyblockEvents.JOIN.register(() -> {
if (config.enabled() && !sentUpdateNotification) checkForNewVersion();
});
}

private static void loadConfig() {
CompletableFuture.supplyAsync(() -> {
try (BufferedReader reader = Files.newBufferedReader(CONFIG_PATH)) {
return Config.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(reader)).getOrThrow();
} catch (NoSuchFileException ignored) {
} catch (Exception e) {
LOGGER.error("[Skyblocker Update Notifications] Failed to load config!", e);
}

return Config.DEFAULT;
}).thenAccept(loadedConfig -> config = loadedConfig);
}

private static void saveConfig() {
try (BufferedWriter writer = Files.newBufferedWriter(CONFIG_PATH)) {
SkyblockerMod.GSON.toJson(Config.CODEC.encodeStart(JsonOps.INSTANCE, config).getOrThrow(), writer);
} catch (Exception e) {
LOGGER.error("[Skyblocker Update Notifications] Failed to save config :(", e);
}
}

private static void checkForNewVersion() {
CompletableFuture.runAsync(() -> {
try {
SemanticVersion version = (SemanticVersion) MOD_VERSION; //Would only fail because someone changed it themselves
String response = Http.sendGetRequest(BASE_URL + "[%22" + MC_VERSION + "%22]");
List<MrVersion> mrVersions = MrVersion.LIST_CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(response)).getOrThrow();

//Set it to true now so that we don't keep re-checking if the data should be discarded
sentUpdateNotification = true;

Optional<MrVersion> newestVersion = mrVersions.stream()
.filter(ver -> Arrays.stream(config.includedChannels()).anyMatch(channel -> channel == ver.channel()))
.filter(mrv -> COMPARATOR.compare(mrv.version(), version) > 0)
.max(Comparator.comparing(MrVersion::version, COMPARATOR));

if (newestVersion.isPresent() && CLIENT.player != null && !shouldDiscard(version, newestVersion.get().version())) {
MrVersion newVersion = newestVersion.get();
String downloadLink = "https://modrinth.com/mod/skyblocker-liap/version/" + newVersion.id();
Text versionText = Text.literal(newVersion.name()).styled(style -> style
.withFormatting(Formatting.GRAY)
.withUnderline(true)
.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, downloadLink)));

CLIENT.player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.updateNotifications.newUpdateMessage", versionText)));
SystemToast.add(CLIENT.getToastManager(), TOAST_TYPE, Text.translatable("skyblocker.updateNotifications.newUpdateToast.title"), Text.stringifiedTranslatable("skyblocker.updateNotifications.newUpdateToast.description", newVersion.version()));
}
} catch (Exception e) {
LOGGER.error("[Skyblocker Update Notifications] Failed to determine if an update is available or not!", e);
}
});
}

private static DataResult<SemanticVersion> parseVersion(String version) {
String formattedVersion = switch (version) {
case String s when s.charAt(0) == 'v' -> s.substring(1);

default -> version;
};

try {
return DataResult.success(SemanticVersion.parse(formattedVersion));
} catch (VersionParsingException e) {
return DataResult.error(() -> "Failed to parse semantic version from string: " + formattedVersion);
}
}

private static boolean isUnofficialAlphaOrBeta(SemanticVersion version) {
return switch (version.getPrereleaseKey().orElse("")) {
case String s when s.startsWith("alpha") -> s.substring(5).charAt(0) == '-';
case String s when s.startsWith("beta") -> s.substring(4).charAt(0) == '-';

default -> false;
};
}

/**
* Since our "unofficial" betas and alphas (from actions) take after the latest release number we want to discard them from the checker
* if the current version is "unofficial" and the major, minor, and patch versions match.
*/
@VisibleForTesting
protected static boolean shouldDiscard(SemanticVersion currentVersion, SemanticVersion latestVersion) {
if (isUnofficialAlphaOrBeta(currentVersion)) {
//We will expect all 3 components to be present

int currentMajor = currentVersion.getVersionComponent(0);
int currentMinor = currentVersion.getVersionComponent(1);
int currentPatch = currentVersion.getVersionComponent(2);

int latestMajor = latestVersion.getVersionComponent(0);
int latestMinor = latestVersion.getVersionComponent(1);
int latestPatch = latestVersion.getVersionComponent(2);

return currentMajor == latestMajor && currentMinor == latestMinor && currentPatch == latestPatch;
}

return false;
}

public record Config(boolean enabled, Channel channel) {
public static final Config DEFAULT = new Config(true, Channel.RELEASE);
private static final Codec<Config> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.BOOL.fieldOf("enabled").forGetter(Config::enabled),
Channel.CODEC.fieldOf("channel").forGetter(Config::channel))
.apply(instance, Config::new));

private Channel[] includedChannels() {
return switch (this.channel) {
case BETA -> new Channel[] { Channel.RELEASE, Channel.BETA };
case ALPHA -> Channel.values();

default -> new Channel[] { this.channel };
};
}

public Config withEnabled(boolean newEnabled) {
return new Config(newEnabled, this.channel);
}

public Config withChannel(Channel newChannel) {
return new Config(this.enabled, newChannel);
}
}

private record MrVersion(String id, String name, SemanticVersion version, Channel channel) {
private static final Codec<MrVersion> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("id").forGetter(MrVersion::id),
Codec.STRING.fieldOf("name").forGetter(MrVersion::name),
SEM_VER_CODEC.fieldOf("version_number").forGetter(MrVersion::version),
Channel.CODEC.fieldOf("version_type").forGetter(MrVersion::channel))
.apply(instance, MrVersion::new));
private static final Codec<List<MrVersion>> LIST_CODEC = CODEC.listOf();
}

public enum Channel implements StringIdentifiable {
RELEASE,
BETA,
ALPHA;

private static final Codec<Channel> CODEC = StringIdentifiable.createBasicCodec(Channel::values);

@Override
public String toString() {
return I18n.translate("skyblocker.config.general.updateChannel.channel." + name());
}

@Override
public String asString() {
return name().toLowerCase();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.hysky.skyblocker.config.categories;

import de.hysky.skyblocker.SkyblockerScreen;
import de.hysky.skyblocker.UpdateNotifications;
import de.hysky.skyblocker.config.ConfigUtils;
import de.hysky.skyblocker.config.SkyblockerConfig;
import de.hysky.skyblocker.config.configs.GeneralConfig;
Expand Down Expand Up @@ -34,6 +35,21 @@ public static ConfigCategory create(SkyblockerConfig defaults, SkyblockerConfig
newValue -> config.general.enableTips = newValue)
.controller(ConfigUtils::createBooleanController)
.build())
.option(Option.<Boolean>createBuilder()
.name(Text.translatable("skyblocker.config.general.updateNotifications"))
.binding(UpdateNotifications.Config.DEFAULT.enabled(),
() -> UpdateNotifications.config.enabled(),
newValue -> UpdateNotifications.config = UpdateNotifications.config.withEnabled(newValue))
.controller(ConfigUtils::createBooleanController)
.build())
.option(Option.<UpdateNotifications.Channel>createBuilder()
.name(Text.translatable("skyblocker.config.general.updateChannel"))
.description(OptionDescription.of(Text.translatable("skyblocker.config.general.updateChannel.@Tooltip")))
.binding(UpdateNotifications.Config.DEFAULT.channel(),
() -> UpdateNotifications.config.channel(),
newValue -> UpdateNotifications.config = UpdateNotifications.config.withChannel(newValue))
.controller(ConfigUtils::createEnumCyclingListController)
.build())
.option(Option.<Boolean>createBuilder()
.name(Text.translatable("skyblocker.config.general.acceptReparty"))
.binding(defaults.general.acceptReparty,
Expand Down
11 changes: 11 additions & 0 deletions src/main/resources/assets/skyblocker/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,13 @@
"skyblocker.config.general.specialEffects.rareDyeDropEffects": "Rare Dye Drop Effects",
"skyblocker.config.general.specialEffects.rareDyeDropEffects.@Tooltip": "Adds a special visual effect triggered upon dropping a rare dye!",

"skyblocker.config.general.updateChannel": "Update Channel",
"skyblocker.config.general.updateChannel.@Tooltip": "Choose between receiving notifications for releases, for both releases and betas, or for releases; betas; and alphas.",
"skyblocker.config.general.updateChannel.channel.ALPHA": "Alpha",
"skyblocker.config.general.updateChannel.channel.BETA": "Beta",
"skyblocker.config.general.updateChannel.channel.RELEASE": "Release",
"skyblocker.config.general.updateNotifications": "Update Notifications",

"skyblocker.config.general.wikiLookup": "Wiki Lookup",
"skyblocker.config.general.wikiLookup.enableWikiLookup": "Enable Wiki Lookup",
"skyblocker.config.general.wikiLookup.enableWikiLookup.@Tooltip": "Opens the wiki page of the hovered item with the F4 key.",
Expand Down Expand Up @@ -785,6 +792,10 @@
"skyblocker.config.uiAndVisuals.waypoints.waypointType.@Tooltip": "Waypoint: Displays a highlight and a beacon beam.\n\nOutlined Waypoint: Displays both a waypoint and an outline.\n\nHighlight: Only displays a highlight.\n\nOutlined Highlight: Displays both a highlight and an outline.\n\nOutline: Only displays an outline.",
"skyblocker.config.uiAndVisuals.waypoints.waypointType.generalNote": "\n\n\nThis option does not apply to all waypoints. Some waypoints such as secret waypoints have their own waypoint type option.",

"skyblocker.updateNotifications.newUpdateMessage": "There's a new Skyblocker update available! %s",
"skyblocker.updateNotifications.newUpdateToast.title": "Skyblocker Update Available!",
"skyblocker.updateNotifications.newUpdateToast.description": "Download version %s!",

"skyblocker.utils.locationUpdateError": "Failed to update your location! Some features of the mod may not work properly :(",

"skyblocker.reparty.notInPartyOrNotLeader": "You must be in a party and be the leader of it to reparty!",
Expand Down
67 changes: 67 additions & 0 deletions src/test/java/de/hysky/skyblocker/UpdateNotificationsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package de.hysky.skyblocker;

import java.util.Comparator;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import com.mojang.serialization.Codec;
import com.mojang.serialization.JavaOps;

import net.fabricmc.loader.api.SemanticVersion;
import net.fabricmc.loader.api.Version;
import net.minecraft.Bootstrap;
import net.minecraft.SharedConstants;

public class UpdateNotificationsTest {
private final Comparator<Version> COMPARATOR = UpdateNotifications.COMPARATOR;
private final Codec<SemanticVersion> SEM_VER_CODEC = UpdateNotifications.SEM_VER_CODEC;
private final SemanticVersion LATEST_VERSION = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0+1.21").getOrThrow();

@BeforeAll
public static void setupEnvironment() {
SharedConstants.createGameVersion();
Bootstrap.initialize();
}

@Test
void testLatestAgainstRegular() {
SemanticVersion regular = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.21.1+1.21").getOrThrow();

//Requires that the latest be newer than this normal release version
Assertions.assertTrue(COMPARATOR.compare(LATEST_VERSION, regular) > 0);
}

@Test
void testLatestAgainstBeta() {
SemanticVersion beta = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0-beta.1+1.21").getOrThrow();

//Requires that the latest be newer than the beta
Assertions.assertTrue(COMPARATOR.compare(LATEST_VERSION, beta) > 0);
}

@Test
void testLatestAgainstAlpha() {
SemanticVersion alpha = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0-alpha.1+1.21").getOrThrow();

//Requires that the latest be newer than the alpha
Assertions.assertTrue(COMPARATOR.compare(LATEST_VERSION, alpha) > 0);
}

@Test
void testLatestAgainstOldAlpha() {
SemanticVersion oldAlpha = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.21.1-alpha-pr-888-afc81df+1.21").getOrThrow();

//Requires the alpha is older than the latest
Assertions.assertEquals(COMPARATOR.compare(oldAlpha, LATEST_VERSION), -1);
}

@Test
void testThatTheCurrentAlphaAgainstLatestShouldBeDiscarded() {
SemanticVersion currentAlpha = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0-alpha-pr-908-fe7d89a+1.21").getOrThrow();

//Requires that the current alpha be discarded against the latest version
Assertions.assertTrue(UpdateNotifications.shouldDiscard(currentAlpha, LATEST_VERSION));
}
}

0 comments on commit 996ff07

Please sign in to comment.