├── .gitignore
├── settings.xml
├── Dockerfile-linuxbuild
├── src
└── main
│ └── java
│ └── desktopKeyboard2Android
│ ├── ShowJavaKeyEvents.java
│ └── DesktopKeyboard2Android.java
├── pom.xml
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .idea/
3 | target/
4 | .classpath
5 | .project
6 | bin/
7 |
8 |
--------------------------------------------------------------------------------
/settings.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 | com.zenjava
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Dockerfile-linuxbuild:
--------------------------------------------------------------------------------
1 | FROM debian:10
2 | RUN apt update && apt install -y git wget sudo fakeroot
3 | RUN apt update && apt install -y java-common libgl1-mesa-glx libxxf86vm1 libcairo2 libgdk-pixbuf2.0-0 libpango1.0-0 libglib2.0-0 libatk1.0-0 libgtk2.0-0 libfontconfig1 libxtst6 libxrender1 libxi6 libasound2 maven
4 | RUN TEMP_DEB="$(mktemp)" && wget -O "$TEMP_DEB" 'https://cdn.azul.com/zulu/bin/zulu8.52.0.23-ca-fx-jdk8.0.282-linux_amd64.deb' && sudo dpkg -i "$TEMP_DEB" && rm -f "$TEMP_DEB"
5 | RUN git clone https://github.com/dportabella/DesktopKeyboard2Android.git
6 | WORKDIR DesktopKeyboard2Android
7 | COPY settings.xml .
8 | RUN mvn jfx:native -e -s"./settings.xml"
9 |
--------------------------------------------------------------------------------
/src/main/java/desktopKeyboard2Android/ShowJavaKeyEvents.java:
--------------------------------------------------------------------------------
1 | package desktopKeyboard2Android;
2 |
3 | import javafx.application.Application;
4 | import javafx.scene.Scene;
5 | import javafx.scene.control.Label;
6 | import javafx.scene.input.KeyCode;
7 | import javafx.scene.input.KeyEvent;
8 | import javafx.stage.Stage;
9 |
10 | public class ShowJavaKeyEvents extends Application {
11 | final int LAST_KEY_CODE_AS_CONTROL_EVENT = KeyCode.DOWN.impl_getCode(); // 0x28
12 | final int LAST_CHAR_AS_CONTROL_EVENT = (int) ' '; // 0x20
13 |
14 | @Override
15 | public void start(Stage primaryStage) {
16 | primaryStage.setTitle("Keyboard");
17 | Scene scene = new Scene(new Label(), 300, 275);
18 | primaryStage.setScene(scene);
19 |
20 | scene.setOnKeyPressed((KeyEvent e) -> {
21 | System.out.print("D[" + keyCodeToString(e.getCode()) + "] ");
22 | });
23 |
24 | scene.setOnKeyReleased((KeyEvent e) -> {
25 | System.out.print("U[" + keyCodeToString(e.getCode()) + "] ");
26 | if (e.getCode().impl_getCode() == 27) { System.out.println(); }
27 | });
28 |
29 | scene.setOnKeyTyped((KeyEvent e) -> {
30 | System.out.print("C[" + keyCharToString(e) + "] ");
31 | });
32 |
33 | primaryStage.show();
34 | }
35 |
36 | private String keyCodeToString(KeyCode keyCode) {
37 | return keyCode.getName() + ":" + keyCode.impl_getCode();
38 | }
39 |
40 | private String keyCharToString(KeyEvent e) {
41 | String c = e.getCharacter();
42 | String cStr = c.replaceAll("\r", "");
43 | String codePointStr = (c.length() == 0) ? "(none)" : String.valueOf(c.codePointAt(0));
44 | return cStr + ":" + codePointStr;
45 | }
46 |
47 | public static void main(String[] args) { launch(args); }
48 | }
49 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | io.githubdportabella
7 | DesktopKeyboard2Android
8 | 1.1-SNAPSHOT
9 |
10 |
11 | David Portabella
12 |
13 |
14 |
15 | https://github.com/dportabella/DesktopKeyboard2Android.git
16 | scm:git:git@github.com:tobrien/git-demo.git
17 | scm:git:git@github.com:dportabella/DesktopKeyboard2Android.git
18 | HEAD
19 |
20 |
21 |
22 | 1.8
23 | 1.8
24 |
25 |
26 |
27 |
28 | org.slf4j
29 | slf4j-api
30 | 1.7.7
31 |
32 |
33 |
34 | ch.qos.logback
35 | logback-classic
36 | 1.1.3
37 |
38 |
39 |
40 | ch.qos.logback
41 | logback-core
42 | 1.1.3
43 |
44 |
45 |
46 |
47 |
48 |
49 | com.zenjava
50 | javafx-maven-plugin
51 | 8.1.5
52 |
53 | desktopKeyboard2Android.DesktopKeyboard2Android
54 | 1.1
55 | true
56 | true
57 | DesktopKeyboard2Android
58 |
59 |
60 |
61 | org.apache.maven.plugins
62 | maven-release-plugin
63 | 2.5.3
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/main/java/desktopKeyboard2Android/DesktopKeyboard2Android.java:
--------------------------------------------------------------------------------
1 | /*
2 | This program, DesktopKeyboard2Android, captures the key events and forwards it to the Android WifiKeyboard app, through Wifi or USB.
3 | It gets the key events received from the laptop, and transform and forward them to WifiKeyboard app accordingly.
4 | This involves mainly, but not only, filtering out some of the KeyPressed/KeyReleased and KeyTyped events.
5 | Because of a lack of documentation about it, the logic implemented here has been based on experimenting, trail and error,
6 | and so it might not work in all systems and keyboards.
7 | See the README.md for my information.
8 |
9 | https://github.com/dportabella/DesktopKeyboard2Android
10 | */
11 |
12 | package desktopKeyboard2Android;
13 |
14 | import javafx.application.Application;
15 | import javafx.scene.Scene;
16 | import javafx.scene.control.TextArea;
17 | import javafx.scene.input.KeyCode;
18 | import javafx.scene.input.KeyEvent;
19 | import javafx.stage.Stage;
20 |
21 | import java.io.BufferedReader;
22 | import java.io.IOException;
23 | import java.io.InputStreamReader;
24 | import java.net.HttpURLConnection;
25 | import java.net.URL;
26 | import java.nio.charset.StandardCharsets;
27 | import java.util.List;
28 | import java.util.stream.Collectors;
29 |
30 | public class DesktopKeyboard2Android extends Application {
31 | final int LAST_KEY_CODE_AS_CONTROL_EVENT = KeyCode.DOWN.impl_getCode(); // 0x28
32 | final int LAST_CHAR_AS_CONTROL_EVENT = (int) ' '; // 0x20
33 |
34 | final int ENTER_KEY_CODE = 10;
35 | final int ENTER_CODE_POINT = 13;
36 | final int ENTER_ANDROID_CODE_POINT = 10;
37 |
38 | private TextArea infoWindow;
39 |
40 | /* used to store and, if necessary, later forward a key used with modifiers (such as Crtl)
41 | * that has not been translated to a usefull typed key.
42 | * For instance:
43 | * Crtl-V produces a KeyTyped event that is not forwarded. So lastIgnoredKeyCode will be forwarded.
44 | * Alt-g in a Swiss-French keyboard produces a KeyTyped event (@ character) that is forwarded. So lastIgnoredKeyCode will not be forwarded.
45 | */
46 | private KeyCode lastIgnoredKeyCode = null;
47 |
48 | private int keySequence = 0;
49 |
50 | @Override
51 | public void start(Stage primaryStage) {
52 | primaryStage.setTitle("Keyboard");
53 |
54 | infoWindow = createInfoWindow();
55 | infoWindow.setText(instrauctions);
56 |
57 | Scene scene = new Scene(infoWindow, 600, 400);
58 | primaryStage.setScene(scene);
59 |
60 | scene.setOnKeyPressed((KeyEvent e) -> {
61 | if (handleAsControlEvent(e.getCode())) {
62 | sendKeyPressed(e.getCode());
63 | lastIgnoredKeyCode = null;
64 | } else {
65 | lastIgnoredKeyCode = e.getCode();
66 | }
67 | });
68 |
69 | scene.setOnKeyReleased((KeyEvent e) -> {
70 | if (handleAsControlEvent(e.getCode())) {
71 | sendKeyReleased(e.getCode());
72 | }
73 | });
74 |
75 | scene.setOnKeyTyped((KeyEvent e) -> {
76 | if (handleAsTypedEvent(e.getCharacter())) {
77 | sendKeyTyped(e.getCharacter());
78 | } else if (lastIgnoredKeyCode != null) {
79 | sendKeyPressed(lastIgnoredKeyCode);
80 | sendKeyReleased(lastIgnoredKeyCode);
81 | lastIgnoredKeyCode = null;
82 | }
83 | });
84 |
85 | primaryStage.show();
86 | }
87 |
88 | private static TextArea createInfoWindow() {
89 | TextArea infoWindow = new TextArea();
90 | infoWindow.setEditable(false);
91 | infoWindow.setDisable(true);
92 | return infoWindow;
93 | }
94 |
95 | private boolean handleAsControlEvent(KeyCode keyCode) {
96 | int code = keyCode.impl_getCode();
97 | return (code != ENTER_KEY_CODE && code <= LAST_KEY_CODE_AS_CONTROL_EVENT);
98 | }
99 |
100 | private boolean handleAsTypedEvent(String c) {
101 | return ( (c.length() == 1 && c.charAt(0) == ENTER_CODE_POINT) // enter key
102 | || (c.length() > 0 && (c.length() > 1 || c.charAt(0) > LAST_CHAR_AS_CONTROL_EVENT)));
103 | }
104 |
105 | private void sendKeyPressed(KeyCode keyCode) {
106 | addInfo("D[" + keyCodeToString(keyCode) + "]");
107 | sendKeyEvent("D" + keyCode.impl_getCode());
108 | }
109 |
110 | private void sendKeyReleased(KeyCode keyCode) {
111 | addInfo("U[" + keyCodeToString(keyCode) + "]");
112 | sendKeyEvent("U" + keyCode.impl_getCode());
113 | }
114 |
115 | private void sendKeyTyped(String str) {
116 | addInfo("C[" + keyCharToString(str) + "]");
117 | int c = str.codePointAt(0); // todo: is it possible to have more than one?
118 | int androidChar = (c == ENTER_CODE_POINT) ? ENTER_ANDROID_CODE_POINT : c;
119 | sendKeyEvent("C" + androidChar);
120 | }
121 |
122 | private String keyCodeToString(KeyCode keyCode) {
123 | return keyCode.getName() + ":" + keyCode.impl_getCode();
124 | }
125 |
126 | private String keyCharToString(String c) {
127 | String cStr = c.replaceAll("\r", "");
128 | String codePointStr = (c.length() == 0) ? "(none)" : String.valueOf(c.codePointAt(0));
129 | return cStr + ":" + codePointStr;
130 | }
131 |
132 | private void sendKeyEvent(String keyEventString) {
133 | keySequence++;
134 | String url = "http://localhost:7777/key?" + keySequence + "," + keyEventString + ",";
135 | List lines;
136 | try {
137 | lines = httpRequest(url);
138 | } catch (IOException e) {
139 | addInfo("connection to androidWifiKeyboard app failed");
140 | return;
141 | }
142 | if (lines.size() != 1 || !"ok".equals(lines.get(0))) {
143 | addInfo("unexpected response from android WifiKeyboard app: " + lines);
144 | }
145 | }
146 |
147 | private List httpRequest(String url) throws IOException {
148 | HttpURLConnection httpc = (HttpURLConnection) new URL(url).openConnection();
149 | try (BufferedReader in = new BufferedReader(new InputStreamReader(httpc.getInputStream(), StandardCharsets.UTF_8))) {
150 | return in.lines().collect(Collectors.toList());
151 | }
152 | }
153 |
154 | private void addInfo(String text) {
155 | infoWindow.appendText(text + "\n");
156 | }
157 |
158 | public static void main(String[] args) {
159 | launch(args);
160 | }
161 |
162 | final static String instrauctions =
163 | "DesktopKeyboard2Android\n" +
164 | "Use your laptop's keyboard for typing in your Android phone\n" +
165 | "https://github.com/dportabella/DesktopKeyboard2Android/\n\n" +
166 | "- Install the WifiKeyboard app: https://play.google.com/store/apps/details?id=com.volosyukivan&hl=en\n" +
167 | "- Follow its instructions to connect your laptop and android together through wifi or usb.\n" +
168 | " this includes executing from the terminal: adb forward tcp:7777 tcp:7777\n" +
169 | "- Test that it works by browsing this web page from your laptop and type some text: http://localhost:7777/\n" +
170 | "- What you type here now, it will be forwarded to your Android\n\n";
171 | }
172 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DesktopKeyboard2Android
2 | ## Use your laptop's keyboard for typing in your Android phone
3 |
4 | [WifiKeyboard](https://play.google.com/store/apps/details?id=com.volosyukivan&hl=en) is a great android app that lets you use your laptop's keyboard to type into your android. You connect your laptop and android together by wifi or usb. In your laptop, you use a web browser, "the client", to type into your android.
5 |
6 | This project replaces "the client", while still using the android app. The differences are:
7 | * The client is a standalone software, instead of a web page
8 | * There are not parameters to set-up (so, no interaction with the application needed)
9 | * It works with non-US keyboards (eg, accents) while it also forwards combination keys.
10 | So for example, you can copy a text from an android app and paste it to another app by using Crt-C & Crtl-V (as if the keyboard was attached directly to the phone, without a laptop being present)
11 |
12 |
13 | ## Use
14 | - Install the [WifiKeyboard](https://play.google.com/store/apps/details?id=com.volosyukivan&hl=en) app
15 | - Follow its instructions to connect your laptop and android together through wifi or usb.
16 | This includes executing from the terminal: `adb forward tcp:7777 tcp:7777`
17 | - Test that it works by browsing this web page from your laptop and type some text: [http://localhost:7777/](http://localhost:7777/)
18 | - Install and run the [latest release of DesktopKeyboard2Android](https://github.com/dportabella/DesktopKeyboard2Android/releases)
19 | - What you type now in your laptop, it will be forwarded to your Android
20 |
21 | You may want to use a hot key (eg Alt+Cmd+X) to launch or re-activate both the `adb forward` command and DesktopKeyboard2Android. Follow the instructions for [OSX](http://www.cnet.com/news/how-to-use-hot-keys-to-launch-applications-in-os-x/), [MsWindows](https://www.google.com/search?hl=en&q=How+to+use+hot+keys+to+launch+applications+in+Ms+Windows) or [Linux](https://www.google.com/search?hl=en&q=How+to+use+hotkeys+to+launch+applications+in+Linux).
22 |
23 |
24 | ## Compile
25 |
26 | ### Retrieve source code
27 |
28 | $ git clone https://github.com/dportabella/DesktopKeyboard2Android.git
29 | $ cd DesktopKeyboard2Android
30 |
31 | ### Execute from source code
32 |
33 | To debug this application:
34 |
35 | $ mvn exec:java -Dexec.mainClass="desktopKeyboard2Android.DesktopKeyboard2Android"
36 |
37 | To show the java key events and understand how they are filtered and mapped for Android:
38 |
39 | $ mvn exec:java -Dexec.mainClass="desktopKeyboard2Android.ShowJavaKeyEvents"
40 |
41 | ### Build a native installer
42 |
43 | $ mvn jfx:native
44 |
45 | #### Java Multi-OS executable
46 |
47 | $ java -jar target/jfx/app/DesktopKeyboard2Android-1.1-SNAPSHOT-jfx.jar
48 |
49 | #### Linux Debian/Ubuntu package
50 |
51 | Install the Debian package:
52 |
53 | $ sudo dpkg -i target/jfx/native/desktopkeyboard2android-1.1.deb
54 |
55 | Start from command line:
56 |
57 | $ /opt/DesktopKeyboard2Android/DesktopKeyboard2Android
58 |
59 | Start from Unity launcher: search for "DesktopKeyboard2Android" (it's case insensitive).
60 |
61 | Start from other desktop: the application is installed under "All Applications" or "Unknown" category.
62 |
63 | There is a desktop starter available in `/opt/DesktopKeyboard2Android/DesktopKeyboard2Android.desktop`.
64 | If you copy it to `~/Desktop/` or `~/.local/share/applications/`, then you should edit it to add `Hidden=false`.
65 | You can also drag n' drop from nautilus to the Unity bar.
66 |
67 | #### Building using Docker
68 |
69 | To build using docker clone the project and then run the following commands:
70 |
71 | ```
72 | sudo docker build . -tbuilddesktopkeyboard2android -f ./Dockerfile-linuxbuild
73 | sudo docker create --name builddesktopkeyboard2android builddesktopkeyboard2android:latest
74 | sudo docker cp builddesktopkeyboard2android:/DesktopKeyboard2Android/target /tmp/
75 | sudo docker rm builddesktopkeyboard2android
76 | sudo docker rmi builddesktopkeyboard2android
77 | ```
78 |
79 | #### Windows
80 |
81 | For creating the installer on MsWindows, you may need to download and install “Inno Setup 5” or later, see the output of the previous command. No requirements for OSX.
82 |
83 |
84 | ## Todo
85 | * Replace the GUI window by a system tray icon
86 | * Remove the need to first run the adb command
87 | * Put the key events in a queue, instead of blocking the application
88 |
89 | ## Notes for developers
90 | Java produces three types of events when typing text: `KeyPressed`, `KeyTyped` and `KeyReleased`. `KeyPressed` and `KeyRelease` have information about the key code, and it always corresponds to a single key. `KeyTyped` is generated by the operating system according to the keyboard layout configuration (eg, a US keyboard or Swiss-French keyboard). It may require to type several keys before getting a `KeyTyped` event. For instance, typing the 'Alt-\`' and then the 'e' keys on my Swiss-French keyboard generates a single KeyTyped event with the 'é' character.
91 |
92 | Some examples of Java KeyEvents:
93 | ```
94 | D[] stands for KeyPressed, with keyCode name and number
95 | U[] stands for KeyReleased, with keyCode name and number
96 | C[] stands for KeyTyped, with character and codePoint
97 |
98 | a D[A:65] C[a:97] U[A:65]
99 | Shift D[Shift:16] U[Shift:16]
100 | Shift-a D[Shift:16] D[A:65] C[A:65] U[A:65] U[Shift:16]
101 | Space D[Space:32] C[ :32] U[Space:32]
102 | Enter D[Enter:10] C[:13] U[Enter:10]
103 | Esc D[Esc:27] C[:(none)] U[Esc:27]
104 | Tab D[Tab:9] C[:9] U[Tab:9]
105 |
106 | In My Swiss-French keyboard, I have a key that directly generates the accented letter 'è':
107 | è D[Open Bracket:91] C[è:232] U[Open Bracket:91]
108 |
109 | For generating 'é', I need to type to keys, first the 'Alt-`' and then the 'e':
110 | Alt` e D[Equals:61] C[:(none)] U[Equals:61] D[E:69] C[ê:234] U[E:69]
111 |
112 | For generating '@', I need to type Alt and G:
113 | alt-g D[Alt:18] D[G:71] C[@:64] U[G:71] U[Alt:18]
114 |
115 | For generating '#', I need to type Alt and 3:
116 | Alt-3 D[Alt:18] D[3:51] C[#:35] U[3:51] U[Alt:18]
117 |
118 | For generating '&', I need to type Shift and -6:
119 | Shift-6 D[Shift:16] D[6:54] C[&:38] U[6:54] U[Shift:16]
120 |
121 | The Up key does not fire a KeyTyped event, only the KeyPressed and KeyReleased event.
122 | Up D[Up:38] U[Up:38]
123 |
124 | Here I select three letters (with Shift Rights) and copy them to the clipboard (with Crtl-C):
125 | D[Shift:16] D[Right:39] U[Right:39] D[Right:39] U[Right:39] D[Right:39] U[Right:39] U[Shift:16]
126 | D[Ctrl:17] D[C:67] C[:3] U[C:67] U[Ctrl:17]
127 | ```
128 |
129 | Note: sometimes, not always, key codes and code points have the same number. But they always have a different semantics. For instance:
130 | ```
131 | KeyPressed/KeyReleased KeyTyped
132 | key code code point
133 | A 65 65
134 | a 65 97
135 | Up 38 -
136 | & - 38
137 | ```
138 |
139 | The '&' character cannot typically be produced by pressing a single key (you need to type Shift-6 in my Swiss-French keyboard to generate it), and so it does not have a key code.
140 | Pressing the Up key does not produce a character, and so it does not have a code point. [Here](https://docs.oracle.com/javase/8/javafx/api/javafx/scene/input/KeyCode.html) you have a list of key codes.
141 |
142 | Note: I do not understand why we get KeyTyped events with some key combinations that do not produce a character. For instance, typing Crlt-C generates also a KeyTyped event with code point 3.
143 |
144 |
145 | This software, DesktopKeyboard2Android, captures the key events and forwards them to the Android WifiKeyboard app, through Wifi or USB.
146 |
147 | Unfortunately, forwarding all key events to WifiKeyboard app generates duplicated or incorrect characters on the phone. For instance:
148 | - typing 'a' should generate only 'a', but WifiKeyboard will generate 'aa' (not even 'Aa', don't know why).
149 | - typing 'Alt-g' on my Swiss-French keyboard should generate only '@', but WifiKeyboard will generate 'G@'.
150 |
151 | One possibility is to forward only the KeyPressed and KeyReleased events. But then we miss the keyboard layout configuration. That is, typing first the 'Alt-\`' and then the 'e' in my Swiss-French keyboard, would generate two characters '\`e' instead of 'é' on the phone. That could be solved if WifiKeyboard app would allow selecting the layout configuration and generate itself the KeyTyped events accordingly. It would be like a combination of WifiKeyboard + [External Keyboard](https://play.google.com/store/apps/details?id=com.medion.android.keyboard&hl=en) apps.
152 |
153 | WikiKeyboard is open-source, but I could not work on it because of my poor working conditions (see below the Story of this application). So, my option was to get the key events received from the laptop, and transform and forward them to WifiKeyboard app accordingly. This involves mainly, but not only, filtering out some of the KeyPressed/KeyReleased and KeyTyped events. Because of a lack of documentation about it, the logic implemented here has been based on experimenting, trail and error, and so it might not work in all systems and keyboards.
154 |
155 |
156 |
157 | ## Story of this application
158 | I am trekking in Nepal Annapurna region for more than one month. I use my laptop to organize photos and type my journal. Suddenly, the retro-illumination of my screen stops working, which means that I can only see very small regions of my screen if I put a torch in front of it, very painful to work with. Typing my journal into my android phone is not feasible. The Google Keyboard app makes it a bit faster to type long texts, but it does not work in the Catalan language.
159 |
160 | I find the WifiKeyboard app, which allows me to use my laptop's keyboard to type into my android. That's fine for writing my journal. However, it is difficult to set-up each time that I want to type something, as the WifiKeyboard client requires to open a webpage in the laptop and click some parameters. That's difficult in my case as the laptop's screen is not working properly. There are two other issues: (1) I cannot type some of the accents on my Swiss-French keyboard, and (2) I cannot reorganize parts of the text as Crtl-C & Crtl-V do not work with the international keyboard.
161 |
162 | So I decided to develop this software to solve these issues while trekking in Nepal Annapurna, with elevations from 2000 to 5000m. I travel in a very relaxed way, trekking long and fast some days, and resting in a hut at the top of a mountain (Tilicho lake, Khopra, Moharedanda) for a few days. The working conditions are a bit painful, as my laptop screen is hardly working, the wifi and internet connection works slowly and intermittently, there are power cuts, I use IntelliJ in a very small screen (I mirror my laptop screen to my Samsung Galaxy S4 using Chrome Remote Desktop), and the whole set-up only works from time to time (anybody can send me detailed instructions on how to mirror my osx laptop to my android phone through usb?). It is also an opportunity to see how would it be to work at the mountains. It is great! No cars, not even bicycles, I can take a break, watch the great landscape, go for a walk, and maybe get a new picture of the problem I am trying to solve. Sometimes the set-up fails (power cut, no wifi/internet) and I am forced to take such a nice break. And no, this does not spoil my trekking vacations. I have just taken three days out of five weeks. It is a good small brake. :)
163 |
164 | Also, this is how it works in "remote areas". If I had been in a big city, I would have just taken my laptop to an Apple Center to fix it, or buy a new one, or buy an external USB keyboard for the android (which means you need money). In a remote area, people improvise solutions with whatever they have right there. Very nice! Also, it shows the big discrimination on access to information: in a big city, we can instantly browse a wikipedia page or watch a coursera.org video course. That was not the case here, with slow and intermittent internet connection.
165 |
--------------------------------------------------------------------------------