1. Virtual Keyboard config editor

2. More json file config options
3. Mod install manifest adjust(silent remove/upgrade support)
This commit is contained in:
ZaneYork 2020-10-21 21:15:02 +08:00
parent a0a8810145
commit 3328a9af52
21 changed files with 208 additions and 44 deletions

View File

@ -66,8 +66,8 @@ dependencies {
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.2'
implementation "androidx.navigation:navigation-fragment:2.3.0"
implementation "androidx.navigation:navigation-ui:2.3.0"
implementation "androidx.navigation:navigation-fragment:2.3.1"
implementation "androidx.navigation:navigation-ui:2.3.1"
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15on
implementation group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.65.01'

View File

@ -1,13 +1,16 @@
[
{
"assetPath":"mods/virtual-keyboard.zip",
"Name": "VirtualKeyboard",
"Name": "648c4b387c5b61642614aa6677909a755a9b84fd96d717703fc83f5f73f515c0board",
"UniqueID": "VirtualKeyboard"
},
{
"assetPath":"mods/custom-localization.zip",
"Name": "CustomLocalization",
"UniqueID": "ZaneYork.CustomLocalization"
"UniqueID": "ZaneYork.CustomLocalization",
"CleanInstall": true,
"Version": "1.1.0",
"OriginUniqueId": ["SMAPI.CustomLocalization"]
},
{
"assetPath":"mods/console-commands.zip",

View File

@ -0,0 +1,8 @@
<!DOCTYPE html><html><head><meta charset=utf-8><title>Virtual Keyboard Config Editor</title><link href=./static/css/app.css rel=stylesheet></head><body><div id=app></div><script>window.getJsonCallback = null;
// window.webObject = { getText: function(){ return '{"vToggle":{"key":"None","rectangle":{"X":36,"Y":12,"Width":64,"Height":64},"autoHidden":false},"buttons":[{"key":"Q","rectangle":{"X":200,"Y":80,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"I","rectangle":{"X":363,"Y":80,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"P","rectangle":{"X":526,"Y":80,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"B","rectangle":{"X":1180,"Y":12,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null}],"buttonsExtend":[{"key":"F1","rectangle":{"X":190,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"F2","rectangle":{"X":290,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"F3","rectangle":{"X":390,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"H","rectangle":{"X":490,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"O","rectangle":{"X":590,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"K","rectangle":{"X":690,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"U","rectangle":{"X":790,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"M","rectangle":{"X":890,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"V","rectangle":{"X":990,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"RightWindows","rectangle":{"X":1090,"Y":170,"Width":90,"Height":90},"transparency":0.5,"alias":"CMD","command":null},{"key":"S","rectangle":{"X":1080,"Y":12,"Width":90,"Height":90},"transparency":0.5,"alias":null,"command":null},{"key":"None","rectangle":{"X":980,"Y":12,"Width":90,"Height":90},"transparency":0.5,"alias":"Zoom","command":"zoom"}]}'; }, getMode: function(){ return 'tree';}, getLanguage: function(){ return 'zh-CN';}, isEditable: function(){ return true;}, getHeight: function(){ return 720;}, getWidth: function(){ return 1280;}, };
window.getJson = function()
{
if(window.getJsonCallback != null) {
window.webObject.setText(JSON.stringify(window.getJsonCallback()));
}
}</script><script type=text/javascript src=./static/js/manifest.js></script><script type=text/javascript src=./static/js/vendor.js></script><script type=text/javascript src=./static/js/app.js></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
!function(r){var n=window.webpackJsonp;window.webpackJsonp=function(e,u,c){for(var f,i,p,a=0,l=[];a<e.length;a++)i=e[a],o[i]&&l.push(o[i][0]),o[i]=0;for(f in u)Object.prototype.hasOwnProperty.call(u,f)&&(r[f]=u[f]);for(n&&n(e,u,c);l.length;)l.shift()();if(c)for(a=0;a<c.length;a++)p=t(t.s=c[a]);return p};var e={},o={2:0};function t(n){if(e[n])return e[n].exports;var o=e[n]={i:n,l:!1,exports:{}};return r[n].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=r,t.c=e,t.d=function(r,n,e){t.o(r,n)||Object.defineProperty(r,n,{configurable:!1,enumerable:!0,get:e})},t.n=function(r){var n=r&&r.__esModule?function(){return r.default}:function(){return r};return t.d(n,"a",n),n},t.o=function(r,n){return Object.prototype.hasOwnProperty.call(r,n)},t.p="./",t.oe=function(r){throw console.error(r),r}}([]);

File diff suppressed because one or more lines are too long

View File

@ -24,6 +24,7 @@ public class WebViewObject {
private String language;
private boolean editable;
private int height;
private int width;
private Consumer<String> setterCallback;
@JavascriptInterface
@ -49,6 +50,11 @@ public class WebViewObject {
return height;
}
@JavascriptInterface
public int getWidth() {
return width;
}
@JavascriptInterface
public String getMode() {
return mode;

View File

@ -56,6 +56,16 @@ public class ModManifestEntry {
*/
private Boolean IsRequired;
/**
* 是否清理安装
*/
private Boolean CleanInstall;
/**
* 原唯一ID列表
*/
private List<String> OriginUniqueId;
/**
* 翻译后的Description
*/

View File

@ -171,15 +171,35 @@ public class ModAssetsManager {
File modFolder = new File(FileUtils.getStadewValleyBasePath(), Constants.MOD_PATH);
ImmutableListMultimap<String, ModManifestEntry> installedModMap = Multimaps.index(findAllInstalledMods(), ModManifestEntry::getUniqueID);
for (ModManifestEntry mod : modManifestEntries) {
if (installedModMap.containsKey(mod.getUniqueID()) || installedModMap.containsKey(mod.getUniqueID().replace("ZaneYork.CustomLocalization", "SMAPI.CustomLocalization"))) {
if (installedModMap.containsKey(mod.getUniqueID())) {
ImmutableList<ModManifestEntry> installedMods = installedModMap.get(mod.getUniqueID());
if (installedMods.size() > 1) {
DialogUtils.showAlertDialog(root, R.string.error,
String.format(context.getString(R.string.duplicate_mod_found),
installedMods.stream().map(item -> FileUtils.toPrettyPath(item.getAssetPath())).collect(Collectors.joining(","))));
return false;
} else if (installedMods.size() == 0) {
installedMods = installedModMap.get(mod.getUniqueID().replace("ZaneYork.CustomLocalization", "SMAPI.CustomLocalization"));
}
if (mod.getCleanInstall() != null && mod.getCleanInstall()) {
// Delete origin version with different Unique ID
if(mod.getOriginUniqueId() != null) {
installedModMap.keys().stream().filter(id -> mod.getOriginUniqueId().contains(id)).map(installedModMap::get).forEach(list -> {
for (ModManifestEntry entry : list) {
try {
FileUtils.deleteDirectory(new File(entry.getAssetPath()));
} catch (IOException e) {
Log.e(TAG, "Install Mod Error", e);
}
}
});
}
// Delete old version
if(installedMods.size() > 0 && mod.getVersion() != null && VersionUtil.compareVersion(installedMods.get(0).getVersion(), mod.getVersion()) < 0) {
try {
FileUtils.deleteDirectory(new File(installedMods.get(0).getAssetPath()));
} catch (IOException e) {
Log.e(TAG, "Install Mod Error", e);
}
}
}
if (installedMods.size() > 0) {
try {
@ -187,13 +207,13 @@ public class ModAssetsManager {
} catch (IOException e) {
Log.e(TAG, "Install Mod Error", e);
}
continue;
}
} else {
try {
ZipUtil.unpack(context.getAssets().open(mod.getAssetPath()), modFolder);
} catch (IOException e) {
Log.e(TAG, "Install Mod Error", e);
}
}
try {
ZipUtil.unpack(context.getAssets().open(mod.getAssetPath()), modFolder);
} catch (IOException e) {
Log.e(TAG, "Install Mod Error", e);
}
}
return true;

View File

@ -1,6 +1,8 @@
package com.zane.smapiinstaller.ui.config;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@ -8,11 +10,9 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.hjq.language.LanguagesManager;
import com.zane.smapiinstaller.BuildConfig;
import com.zane.smapiinstaller.R;
@ -28,7 +28,6 @@ import com.zane.smapiinstaller.utils.JsonUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.util.Locale;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
@ -40,6 +39,7 @@ import androidx.navigation.Navigation;
*/
public class ConfigEditFragment extends Fragment {
private Boolean editable;
private Boolean virtualKeyboardConfigMode;
private String configPath;
private FragmentConfigEditBinding binding;
@ -60,6 +60,7 @@ public class ConfigEditFragment extends Fragment {
CommonLogic.doOnNonNull(this.getArguments(), arguments -> {
ConfigEditFragmentArgs args = ConfigEditFragmentArgs.fromBundle(arguments);
editable = args.getEditable();
virtualKeyboardConfigMode = args.getVirtualKeyboardConfigMode();
if (!editable) {
binding.buttonConfigSave.setVisibility(View.INVISIBLE);
binding.buttonConfigCancel.setVisibility(View.INVISIBLE);
@ -75,7 +76,6 @@ public class ConfigEditFragment extends Fragment {
if (fileText != null) {
binding.scrollView.post(() -> {
CommonLogic.doOnNonNull(this.getContext(), (context -> {
int height = (int) (binding.scrollView.getMeasuredHeight() / context.getResources().getDisplayMetrics().density * 0.95);
String lang = LanguagesManager.getAppLanguage(context).getCountry();
switch (lang) {
case "zh":
@ -90,17 +90,47 @@ public class ConfigEditFragment extends Fragment {
default:
break;
}
if (editable) {
webObject = new WebViewObject(fileText, "tree", lang, true, height, this::configSave);
binding.editTextConfigWebview.addJavascriptInterface(webObject, "webObject");
} else {
webObject = new WebViewObject(fileText, "text-plain", lang, false, height, null);
binding.editTextConfigWebview.addJavascriptInterface(webObject, "webObject");
String assetText;
String baseUrl;
if(!virtualKeyboardConfigMode) {
int height = (int) (binding.scrollView.getMeasuredHeight() / context.getResources().getDisplayMetrics().density * 0.95);
if (editable) {
try {
JsonUtil.checkJson(fileText);
String jsonText = JsonUtil.toJson(JsonUtil.fromJson(fileText, Object.class));
webObject = new WebViewObject(jsonText, "tree", lang, true, height, 0, this::configSave);
binding.editTextConfigWebview.addJavascriptInterface(webObject, "webObject");
} catch (Exception e) {
DialogUtils.showAlertDialog(getView(), R.string.error, e.getLocalizedMessage());
return;
}
} else {
webObject = new WebViewObject(fileText, "text-plain", lang, false, height, 0, null);
binding.editTextConfigWebview.addJavascriptInterface(webObject, "webObject");
}
baseUrl = "file:///android_asset/jsoneditor/";
assetText = FileUtils.getAssetText(context, "jsoneditor/editor.html");
Activity activity = CommonLogic.getActivityFromView(binding.editTextConfigWebview);
if(activity.getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT){
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
}
else {
int height = context.getResources().getDisplayMetrics().heightPixels;
int width = context.getResources().getDisplayMetrics().widthPixels;
webObject = new WebViewObject(fileText, "tree", lang, true, height, width, this::configSave);
binding.editTextConfigWebview.addJavascriptInterface(webObject, "webObject");
binding.editTextConfigWebview.setInitialScale(100);
assetText = FileUtils.getAssetText(context, "vkconfig/index.html");
baseUrl = "file:///android_asset/vkconfig/";
Activity activity = CommonLogic.getActivityFromView(binding.editTextConfigWebview);
if(activity.getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE){
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
}
}
String assetText = FileUtils.getAssetText(context, "jsoneditor/editor.html");
if (assetText != null) {
binding.editTextConfigWebview.loadDataWithBaseURL(
"file:///android_asset/jsoneditor/",
baseUrl,
assetText,
"text/html",
"utf-8", "");

View File

@ -21,6 +21,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.navigation.NavController;
@ -51,7 +52,7 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
ModManifestEntry mod = modList.get(position);
holder.binding.textViewModName.setText(mod.getName());
holder.binding.textViewModDescription.setText(StringUtils.firstNonBlank(mod.getTranslatedDescription(), mod.getDescription()));
holder.setModPath(mod.getAssetPath());
holder.setModInfo(mod);
}
@Override
@ -66,12 +67,22 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
class ViewHolder extends RecyclerView.ViewHolder {
private ModListItemBinding binding;
private String modPath;
private ModManifestEntry modInfo;
private List<String> configList;
void setModPath(String modPath) {
this.modPath = modPath;
File file = new File(modPath, "config.json");
if (!file.exists()) {
void setModInfo(ModManifestEntry modInfo) {
this.modInfo = modInfo;
File file = new File(modInfo.getAssetPath(), "config.json");
configList = FileUtils.listAll(modInfo.getAssetPath(), (f) ->
!StringUtils.equals(f.getAbsolutePath(), file.getAbsolutePath())
&& f.getName().endsWith(".json")
&& !f.getName().startsWith(".")
&& !StringUtils.equals(f.getName(), "manifest.json")
);
if (file.exists()) {
configList.add(0, file.getAbsolutePath());
}
if (configList.size() == 0) {
binding.buttonConfigMod.setVisibility(View.INVISIBLE);
} else {
binding.buttonConfigMod.setVisibility(View.VISIBLE);
@ -88,7 +99,7 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
}
private void setStrike() {
File file = new File(modPath);
File file = new File(modInfo.getAssetPath());
if (StringUtils.startsWith(file.getName(), Constants.HIDDEN_FILE_PREFIX)) {
binding.textViewModName.getPaint().setFlags(Paint.STRIKE_THRU_TEXT_FLAG);
} else {
@ -110,12 +121,12 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
void removeMod() {
DialogUtils.showConfirmDialog(itemView, R.string.confirm, R.string.confirm_delete_content, (dialog, which) -> {
if (which == DialogAction.POSITIVE) {
File file = new File(modPath);
File file = new File(modInfo.getAssetPath());
if (file.exists()) {
try {
FileUtils.forceDelete(file);
model.removeAll(entry -> StringUtils.equals(entry.getAssetPath(), modPath));
List<Integer> removed = removeAll(entry -> StringUtils.equals(entry.getAssetPath(), modPath));
model.removeAll(entry -> StringUtils.equals(entry.getAssetPath(), modInfo.getAssetPath()));
List<Integer> removed = removeAll(entry -> StringUtils.equals(entry.getAssetPath(), modInfo.getAssetPath()));
for (int idx : removed) {
notifyItemRemoved(idx);
}
@ -128,7 +139,7 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
}
void disableMod() {
File file = new File(modPath);
File file = new File(modInfo.getAssetPath());
if (file.exists() && file.isDirectory()) {
if (StringUtils.startsWith(file.getName(), Constants.HIDDEN_FILE_PREFIX)) {
File newFile = new File(file.getParent(), StringUtils.stripStart(file.getName(), "."));
@ -156,7 +167,7 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
private void moveMod(File file, File newFile) {
try {
FileUtils.moveDirectory(file, newFile);
Integer idx = findFirst(mod -> StringUtils.equalsIgnoreCase(mod.getAssetPath(), modPath));
Integer idx = findFirst(mod -> StringUtils.equalsIgnoreCase(mod.getAssetPath(), modInfo.getAssetPath()));
if (idx != null) {
modList.get(idx).setAssetPath(newFile.getAbsolutePath());
notifyItemChanged(idx);
@ -167,10 +178,30 @@ public class ModManifestAdapter extends RecyclerView.Adapter<ModManifestAdapter.
}
void configMod() {
File file = new File(modPath, "config.json");
if (file.exists()) {
NavController controller = Navigation.findNavController(itemView);
MobileNavigationDirections.ActionNavAnyToConfigEditFragment action = ConfigFragmentDirections.actionNavAnyToConfigEditFragment(file.getAbsolutePath());
if (configList.size() > 0) {
if (configList.size() > 1) {
List<String> selections = configList.stream().map(path -> StringUtils.removeStart(path, modInfo.getAssetPath())).collect(Collectors.toList());
DialogUtils.showListItemsDialog(itemView, R.string.menu_config_edit, selections, (materialDialog, index) -> {
navigateToConfigEditor(configList.get(index));
});
} else {
navigateToConfigEditor(configList.get(0));
}
}
}
private void navigateToConfigEditor(String path) {
NavController controller = Navigation.findNavController(itemView);
MobileNavigationDirections.ActionNavAnyToConfigEditFragment action;
action = ConfigFragmentDirections.actionNavAnyToConfigEditFragment(path);
if ("VirtualKeyboard".equals(modInfo.getUniqueID()) && path.equals(new File(modInfo.getAssetPath(), "config.json").getAbsolutePath())) {
DialogUtils.showListItemsDialog(itemView, R.string.menu_config_edit, R.array.vk_config_mode, ((materialDialog, index) -> {
if (index == 0) {
action.setVirtualKeyboardConfigMode(true);
}
controller.navigate(action);
}));
} else {
controller.navigate(action);
}
}

View File

@ -14,6 +14,7 @@ import com.zane.smapiinstaller.R;
import com.zane.smapiinstaller.constant.DialogAction;
import com.zane.smapiinstaller.logic.CommonLogic;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
@ -356,4 +357,16 @@ public class DialogUtils {
materialDialog.show();
});
}
public static void showListItemsDialog(View view, int title, List<String> items, BiConsumer<MaterialDialog, Integer> callback) {
CommonLogic.runOnUiThread(CommonLogic.getActivityFromView(view), (activity) -> {
MaterialDialog materialDialog = new MaterialDialog(activity, MaterialDialog.getDEFAULT_BEHAVIOR()).title(title, null);
materialDialog = DialogListExtKt.listItems(materialDialog, null, items, null, false, (dialog, position, text) -> {
callback.accept(dialog, position);
return null;
});
DialogUtils.setCurrentDialog(materialDialog);
materialDialog.show();
});
}
}

View File

@ -5,9 +5,12 @@ import android.os.Environment;
import android.util.Log;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.hash.Hashing;
import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams;
import com.google.common.io.Files;
import com.hjq.language.LanguagesManager;
import org.apache.commons.io.input.BOMInputStream;
@ -21,6 +24,10 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* 文件工具类
@ -36,7 +43,7 @@ public class FileUtils extends org.zeroturnaround.zip.commons.FileUtils {
*/
public static String getFileText(File file) {
try {
InputStream inputStream = new FileInputStream(file);
InputStream inputStream = new BOMInputStream(new FileInputStream(file));
try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
@ -314,4 +321,12 @@ public class FileUtils extends org.zeroturnaround.zip.commons.FileUtils {
public static String getStadewValleyBasePath() {
return Environment.getExternalStorageDirectory().getAbsolutePath();
}
public static List<String> listAll(String basePath, Predicate<File> filter) {
return Lists.newArrayList(
Iterables.transform(
Iterables.filter(Files.fileTraverser().breadthFirst(new File(basePath)), filter::test),
File::getAbsolutePath)
);
}
}

View File

@ -24,6 +24,7 @@
android:name="com.zane.smapiinstaller.ui.config.ConfigEditFragment"
android:label="@string/menu_config_edit" >
<argument android:name="configPath" app:argType="string"/>
<argument android:name="virtualKeyboardConfigMode" android:defaultValue="false" app:argType="boolean"/>
<argument android:name="editable" android:defaultValue="true" app:argType="boolean"/>
</fragment>
<fragment

View File

@ -30,4 +30,8 @@
<item>日期(正序)</item>
<item>日期(倒序)</item>
</string-array>
<string-array name="vk_config_mode">
<item>圖形</item>
<item>文本</item>
</string-array>
</resources>

View File

@ -30,4 +30,8 @@
<item>日期(正序)</item>
<item>日期(倒序)</item>
</string-array>
<string-array name="vk_config_mode">
<item>图形</item>
<item>文本</item>
</string-array>
</resources>

View File

@ -35,4 +35,8 @@
<item>1078428449</item>
<item>980882763</item>
</string-array>
<string-array name="vk_config_mode">
<item>Graphics</item>
<item>Text</item>
</string-array>
</resources>

View File

@ -10,7 +10,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:4.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.0"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.1"
classpath 'org.greenrobot:greendao-gradle-plugin:3.3.0'
// NOTE: Do not place your application dependencies here; they belong