-
Notifications
You must be signed in to change notification settings - Fork 25.2k
Add Settings Module Support for the Android Platform #13142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fe2b7ea
7add8a6
2ddc9e6
47fc744
11ef48f
63d4d55
d5e7a55
5b96905
57ca0d0
bd5b23d
0fef2c3
dbb65a6
9b2a396
1aee785
724c18c
f776849
1214f84
c1a7ed8
4f11f67
18e3561
d494eaa
8ffa9c7
92371df
5c8d616
94bd032
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| /** | ||
| * Copyright (c) 2015-present, Facebook, Inc. | ||
| * All rights reserved. | ||
| * | ||
| * This source code is licensed under the BSD-style license found in the | ||
| * LICENSE file in the root directory of this source tree. An additional grant | ||
| * of patent rights can be found in the PATENTS file in the same directory. | ||
| */ | ||
| 'use strict'; | ||
|
|
||
| jest.unmock('../Settings'); | ||
| jest.unmock('Platform'); | ||
|
|
||
| jest.unmock('BatchedBridge'); | ||
| jest.unmock('defineLazyObjectProperty'); | ||
| jest.unmock('MessageQueue'); | ||
| jest.unmock('NativeModules'); | ||
|
|
||
| let Platform = require('Platform'); | ||
|
|
||
| const Settings = require('Settings'); | ||
|
|
||
| describe('Settings', () => { | ||
|
|
||
| describe('setting and getting', () => { | ||
| it('should set values and retrieve them', () => { | ||
|
|
||
| if (Platform.OS == 'ios') { | ||
| expect(true).toEqual(true); | ||
| return; | ||
| } | ||
|
|
||
| Settings.set({oneKey: 1}); | ||
| Settings.set({twoKey: 2.0}); | ||
| Settings.set({threeKey: 'three'}); | ||
|
|
||
| const oneValue = Settings.get('oneKey'); | ||
| const twoValue = Settings.get('twoKey'); | ||
| const threeValue = Settings.get('threeKey'); | ||
|
|
||
| expect(oneValue === 1).toBe(true); | ||
| expect(twoValue === 2.0).toBe(true); | ||
| expect(threeValue === 'three').toBe(true); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| include_defs("//ReactAndroid/DEFS") | ||
|
|
||
| android_library( | ||
| name = "settings", | ||
| srcs = glob(["**/*.java"]), | ||
| visibility = [ | ||
| "PUBLIC", | ||
| ], | ||
| deps = [ | ||
| react_native_dep("third-party/java/infer-annotations:infer-annotations"), | ||
| react_native_dep("third-party/java/jsr-305:jsr-305"), | ||
| react_native_target("java/com/facebook/react/bridge:bridge"), | ||
| react_native_target("java/com/facebook/react/common:common"), | ||
| react_native_target("java/com/facebook/react/module/annotations:annotations"), | ||
| react_native_target("java/com/facebook/react/modules/core:core"), | ||
| ], | ||
| ) | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| /* | ||
| * Copyright (c) 2015-present, Facebook, Inc. | ||
| * All rights reserved. | ||
| * | ||
| * This source code is licensed under the BSD-style license found in the | ||
| * LICENSE file in the root directory of this source tree. An additional grant | ||
| * of patent rights can be found in the PATENTS file in the same directory. | ||
| */ | ||
|
|
||
| package com.facebook.react.modules.settings; | ||
|
|
||
| import android.content.Context; | ||
| import android.content.SharedPreferences; | ||
| import android.util.Log; | ||
|
|
||
| import com.facebook.react.bridge.Arguments; | ||
| import com.facebook.react.bridge.ReactApplicationContext; | ||
| import com.facebook.react.bridge.ReactContextBaseJavaModule; | ||
| import com.facebook.react.bridge.ReactMethod; | ||
| import com.facebook.react.bridge.ReadableMap; | ||
| import com.facebook.react.bridge.ReadableMapKeySetIterator; | ||
| import com.facebook.react.bridge.ReadableType; | ||
| import com.facebook.react.bridge.WritableMap; | ||
| import com.facebook.react.module.annotations.ReactModule; | ||
| import com.facebook.react.modules.core.DeviceEventManagerModule; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
|
|
||
| @ReactModule(name = SettingsModule.NAME) | ||
| public class SettingsModule extends ReactContextBaseJavaModule implements SharedPreferences.OnSharedPreferenceChangeListener{ | ||
|
|
||
| @SuppressWarnings("WeakerAccess") | ||
| static final String NAME = "SettingsManager"; | ||
| static final String DEFAULT_FILE_NAME = "ReactNative"; | ||
| static final String TAG = "SettingsMmodule"; | ||
|
|
||
| private static boolean sSettingsModuleInstantiated = false; | ||
|
|
||
| static private String sFilename = ""; | ||
| private Boolean ignoringUpdates = false; | ||
| private SharedPreferences mPreferences; | ||
|
|
||
| public SettingsModule(ReactApplicationContext context) { | ||
| super(context); | ||
|
|
||
| } | ||
|
|
||
| @Override | ||
| public String getName() { | ||
| return SettingsModule.NAME; | ||
| } | ||
|
|
||
| private SharedPreferences ensurePreferences() { | ||
| if (mPreferences == null) { | ||
| if (SettingsModule.sFilename.length() > 0) { | ||
| mPreferences = getReactApplicationContext().getSharedPreferences(SettingsModule.sFilename, Context.MODE_PRIVATE); | ||
| } else if (getCurrentActivity() != null) { | ||
| mPreferences = getCurrentActivity().getPreferences(Context.MODE_PRIVATE); | ||
| } else { | ||
| mPreferences = getReactApplicationContext().getSharedPreferences(DEFAULT_FILE_NAME, Context.MODE_PRIVATE); | ||
| } | ||
| sSettingsModuleInstantiated = true; | ||
| mPreferences.registerOnSharedPreferenceChangeListener(this); | ||
| } | ||
|
|
||
| return mPreferences; | ||
| } | ||
|
|
||
| /*** | ||
| * Configure the Settings Module to use a SharedPreferences file with a specific name as its backing store. | ||
| * Useful if the Settings Module needs to use an existing SharedPreferences file or to ensure that Settings Modules | ||
| * belonging to different ReactPackages will all share the same SharedPreferences file for their backing store. | ||
| * <p></p> | ||
| * <b>Note:</b>this method may only be called before a Settings Module initialization (i.e. before a ReactMainPackage is instantiated) | ||
| * An app would typically invoke this function in the app's main activity before setting up the React Native environment | ||
| * @param filename the name of the SharedPreferences file to use or create for the backing store | ||
| * @throws IllegalStateException if called after instantiation of a Settings module | ||
| */ | ||
| static public void setSharedPreferencesFilename(String filename) { | ||
| if (sSettingsModuleInstantiated) { | ||
| throw new IllegalStateException("Attempt to set Settings Module SharedPreferences file name after a Settings Module instantiation"); | ||
| } | ||
| sFilename = filename; | ||
| } | ||
|
|
||
| @Override | ||
| public void onCatalystInstanceDestroy() { | ||
| ensurePreferences().unregisterOnSharedPreferenceChangeListener(this); | ||
| } | ||
|
|
||
| @ReactMethod | ||
| public void setValues(ReadableMap values) { | ||
| this.ignoringUpdates = true; | ||
|
|
||
| ReadableMapKeySetIterator iterator = values.keySetIterator(); | ||
| SharedPreferences.Editor editor = mPreferences.edit(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I hope I am not becoming annoying :D but why do you use |
||
|
|
||
| while (iterator.hasNextKey()) { | ||
| String key = iterator.nextKey(); | ||
| ReadableType type = values.getType(key); | ||
|
|
||
| switch (type) { | ||
| case Null: | ||
| editor.remove(key); | ||
| break; | ||
| case Boolean: | ||
| editor.putBoolean(key, values.getBoolean(key)); | ||
| break; | ||
| case Number: | ||
| editor.putFloat(key, (float) values.getDouble(key)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We still need to have the case when the value is out of the range of
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remember these are the persisted settings values, not the current values of the Settings Module. Whereas AsyncStorage has to read and write its stored values asynchronously through native code, Settings reads and writes its settings immediately and synchronously all within its javascript code. It is true that the JS-side settings value are then lazily and asynchronously propagated to native code - both to inform the native app of any changes and also to persist the settings values for a subsequent relaunch of the app. But the values being stored here are not the values that Settings Module is actually providing to its clients. Therefore, there is no reason to use a promise here because the Settings module is basically unaware and unaffected by what this method is doing. The double-to-float conversion does affect the persisted numeric values that populate the Settings Module upon launch. Because these loaded numeric values have been "round tripped" through a smaller type, they may wind up losing precision or over/under flowing their representation. Although this information loss is unfortunate - as a practical matter, few settings are likely to be affected - either because they are integers or they are far, far smaller than 3.4 × 10³⁸ - the upper range of a float. Moreover, it is not a error for a number to be too large or too small for a floating point type to represent - for such numbers, infinity is used for the huge ones and zero for the tiny values.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am aware that the settings values live in javascript and are also asynchronously (although I would't say lazily) sent to native land and persisted. The problem here is that you can store an object to preferences, for example With the current implementation, people can store things without knowing they weren't saved or that they weren't saved exactly the way they were meant to be saved (e.g. because the settings module does not support storing nulls or because there was a trouble casting their large number they wanted to save). This silent failing is very developer-unfriendly. That is why I suggested that a promise would be helpful - if such case happens, the promise could reject. I think it pays off to do things right here - many people will likely use this module (myself included, which is why I did the review). Now, I am not a person that has the rights to merge this PR, so maybe you can go ahead and ask some core contributor to merge, but I think they will also want to make the module more error-proof. I think I have said all that I wanted, so good luck getting this merged 👍
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe the easiest and the best way to handle this would be to put a simple check to the set method in Javascript. It would make sure only non-null strings / numbers / bools can be saved. |
||
| break; | ||
| case String: | ||
| editor.putString(key, values.getString(key)); | ||
| break; | ||
| case Map: // Not supported | ||
| throw new IllegalArgumentException(TAG + ": Cannot not store Map as value in Settings"); | ||
| case Array: // Not supported | ||
| throw new IllegalArgumentException(TAG + ": Cannot not store Array as value in Settings"); | ||
| } | ||
| } | ||
| editor.apply(); | ||
|
|
||
| this.ignoringUpdates = false; | ||
| } | ||
|
|
||
| public Map<String, Object> getConstants() { | ||
| Map<String, Object> exportedValuePairs = new HashMap<String, Object>(); | ||
| Map<String, ?> prefsMap = ensurePreferences().getAll(); | ||
|
|
||
| for (String key : prefsMap.keySet()) { | ||
| Object value = prefsMap.get(key); | ||
| if (value instanceof String || value.getClass().isPrimitive()) { | ||
| exportedValuePairs.put(key, value); | ||
| } else { | ||
| // StringSets are not exported - could be used in the future to store JSON encoded arrays and maps | ||
| Log.d(TAG, "unexported setting: " + key); | ||
| } | ||
| } | ||
|
|
||
| HashMap<String, Object> constants = new HashMap<>(); | ||
|
|
||
| constants.put("settings", exportedValuePairs); | ||
| return constants; | ||
| } | ||
|
|
||
| @Override | ||
| public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String prefKey) { | ||
| if (ignoringUpdates) { | ||
| return; | ||
| } | ||
| Map<String, ?> prefsMap = ensurePreferences().getAll(); | ||
|
|
||
| WritableMap map = Arguments.createMap(); | ||
|
|
||
| for (String key: prefsMap.keySet()) { | ||
| Object value = prefsMap.get(key); | ||
|
|
||
| if (value == null) { | ||
| map.putNull(key); | ||
| } else if (value instanceof String) { | ||
| map.putString(key, (String) value); | ||
| } else if (value instanceof Number) { | ||
| if (value instanceof Integer) { | ||
| map.putInt(key, (Integer) value); | ||
| } else { | ||
| map.putDouble(key, ((Number) value).doubleValue()); | ||
| } | ||
| } else if (value instanceof Boolean) { | ||
| map.putBoolean(key, (Boolean) value); | ||
| } else { | ||
| Log.d(TAG, "ignoring setting: " + key); | ||
| } | ||
| } | ||
| if (getReactApplicationContext().hasActiveCatalystInstance()) { | ||
| getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) | ||
| .emit("settingsUpdated", map); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not familiar with this android functionality, but I believe there is a difference in using getSharedPreferences vs getPreferences
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, and differing calls here are entirely intentional.
getSharedPreferences()lets the caller specify a specific file name in which to store the shared preferences, whereasgetPreferences()stores the preferences values in a file with a default name.In the section above we allow the programmer the option of storing the preferences in a file with a specific name (say, to match an existing shared preferences file already in use by the application), or, to simply store the preferences in a file with a default name.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
according to the docs,
getPreferencesreturnsSo by default, the preferences file created by this module would be available only to the activity that created it. That may or may not be what you want, and could be changed by calling the
setFilenamemethod. That brings us to another question - how do you callsetFilenamefrom the javascript side?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The settings file created by the Settings Module is accessible to that Settings module and therefore indirectly accessible to any Activity or to any other Java class that imports the Settings module.
The only potential concern raised by calling
getPreferences()here is for apps that have multiple Activities each instantiating a MainReactPackage. This is by no means a common occurrence, but if an app is engaging in this behavior - then having each Settings Module in each MainReactPackage use its own, private settings store seems like the most reasonable default behavior.Nevertheless, if separate settings files are not desired, that is, if the developer wants to ensure that every Settings Module instantiated anywhere in the the app will always use the same settings file - then that developer merely has to provide a file name to the Settings Module, and - as long as they take care that only one Settings Module is ever extant at one time - it will be the case that every Settings Module created anywhere in the app will use the same settings file which will have the provided name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the explanation @greghe. I'm just unsure if it is possible to call
setFilenamefrom Javascript with the current impl?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've renamed
setFilenametosetSharedPreferencesFilenameand documented its proper use.setSharedPreferencesFilenamemay not be called after a Settings Module has been instantiated (this is, before a ReactMainPackage initialization) - and will throw an exception if this requirement is not met.Basically, this method exists as a convenient customization point for a developer with specialized requirements regarding handling of the Shared Preferences file - and would typically be invoked from the app's main activity just before it sets up the RN environment.
Therefore invoking
setSharedPreferencesFilenameis not possible from any JS code -because by the time the JS code would be executing, the Settings Module and its shared preferences file will already be up and running.