Compare commits

...
This repository has been archived on 2019-12-07. You can view files and clone it, but cannot push or open issues or pull requests.

134 commits

Author SHA1 Message Date
b9eddea5b9 Added project discontinued notice 2017-01-04 11:11:19 +09:00
e05e34e0fb Added license badge 2016-10-09 18:50:11 +09:00
de846891fc Add Travis CI 2016-10-09 17:34:35 +09:00
a4a50d0341 Fixed Android tests to work on emulator 2016-10-09 14:05:36 +09:00
f50d0697e6 Updated to latest build tools
Replaced scala plugin with fork at
https://github.com/xelnaga/gradle-android-scala-plugin

This fixes #56
2016-09-30 17:22:11 +09:00
8334cd3926 Don't crash if device doesn't have Bluetooth 2016-09-30 16:50:27 +09:00
2ed57f6dec Change description to mention end-to-end encryption (ref #52) 2016-09-30 12:39:10 +09:00
e497c71fb0 Added wiki link for server setup to readme 2016-09-29 16:11:39 +09:00
fa2425b88c Bumped version to 0.5.2 2016-09-28 12:02:09 +09:00
8f5d10f0c0 Removed maven central repository 2016-09-27 10:44:30 +09:00
a22b32f607 Retry internet connections if all nodes disconnected 2016-09-25 20:17:26 +09:00
75d87b54c2 Also copy ID to clipboard on long click 2016-09-25 16:27:11 +09:00
858a2ac636 Always show QR code in UserInfoFragment 2016-09-25 15:31:55 +09:00
0ccfd100d3 Clicking on address copies it to the clipboard 2016-09-24 23:41:54 +09:00
f579608f6b Don't crash if we can't bind to server socket 2016-09-24 19:03:49 +09:00
a857002fa9 Fixed possible crash in Bluetooth code 2016-09-24 18:59:24 +09:00
d1768ea8b4 Removed exit button, made app always run on device start 2016-09-24 18:56:52 +09:00
6d65b739c6 Uncommented integration test code 2016-09-24 18:51:02 +09:00
ab5370cd86 Removed unused MaxConnections setting 2016-09-24 18:50:46 +09:00
68cb2253d9 Bumped version to 0.5.1 2016-09-23 21:40:58 +09:00
e9da8cd1f5 Updated slick to avoid crashes at startup 2016-09-22 09:42:54 +09:00
c4ee46c038 Bumped version to 0.5.0 2016-09-21 07:57:34 +09:00
81d8230abc Updated dependencies 2016-09-19 16:48:51 +09:00
083c0a2d03 Fixed Bluetooth scan not working on Android 6.0 2016-09-19 16:31:35 +09:00
1d7055f1d6 Fixed systemd unit 2016-09-19 06:09:54 +09:00
5bb97a1460 Removed maxConnections limit 2016-09-19 06:00:35 +09:00
69066c6744 Use single jar for release, no need for shell script 2016-09-19 05:31:20 +09:00
1d8736931f Added new default server 2016-09-19 05:03:49 +09:00
f3ec28fef8 Allow adding other device by address, auto request public key 2016-09-13 01:48:58 +02:00
127e4c9ff2 Formatting fixes 2016-08-27 03:08:20 +02:00
8bec10efb6 Changed license to MPLv2 (fixes #47) 2016-08-27 02:00:15 +02:00
1be5ca31ab Fixed screenshot size 2016-08-27 01:51:08 +02:00
213046048a Added bachelor thesis 2016-08-26 17:37:14 +02:00
8f2d0cfb73 Updated readme and screenshots 2016-08-26 17:30:05 +02:00
e886f1563a Limit retry count value to 6 2016-08-20 16:18:46 +02:00
882b518a7c Refactored class layout of core package. 2016-08-20 16:13:29 +02:00
7c9c6803dc Also sign header data 2016-08-20 15:48:07 +02:00
64fae3df99 Bumped version to 0.4.0 2016-07-17 18:09:36 +02:00
6bebfcf3cf Fixed crash on database upgrade. 2016-07-17 17:51:36 +02:00
343c5fca07 Fixed crashes in ChatFragment due to wrong date class. 2016-07-17 17:34:37 +02:00
acbcd68384 Added logging to MessageBuffer. 2016-07-17 17:34:24 +02:00
51b1feee4e Merge branch 'message-confirmations' 2016-07-17 17:05:31 +02:00
579d1f5717 Added message confirmations to protocol (ref #22). 2016-07-17 17:05:12 +02:00
338a51fea9 Fixed database test. 2016-07-15 21:06:57 +02:00
c1c7487d48 Force initial route generation for route change test. 2016-07-15 20:46:59 +02:00
6799deea9d Fixed crash when picking relays if a neighbor wasn't in the db. 2016-07-15 20:45:29 +02:00
989ec6efb1 Fixed test compile error. 2016-07-01 21:55:14 +02:00
45bb01cd8e Don't accept message if number of forwarding tokens is too high. 2016-07-01 21:55:05 +02:00
5310c34218 Merge branch 'relay-servers' 2016-06-24 13:35:42 +02:00
4a36fdbef2 Send messages via relays (fixes #26). 2016-06-24 13:34:59 +02:00
1add05b72f Store total node connection time in database. 2016-06-07 20:10:10 +02:00
23ee0f6da7 Added message buffer with automatic retry. 2016-06-02 21:36:48 +02:00
a0cd4248d9 Automatically delete old configs on integration test start. 2016-05-31 13:21:47 +02:00
7ce2e937ab Added extra trace logging for RREQ/RREP/RERR messages. 2016-05-31 12:26:23 +02:00
09ef3b3705 Bumped version to 0.3.0 2016-05-29 19:31:56 +02:00
d97986ae81 Merge branch 'aodvv2' 2016-05-29 18:38:21 +02:00
83fc696cc7 Implemented AODVv2, including integration test (fixes #33).
For documentation on how AODVv2 works, see this link:
https://datatracker.ietf.org/doc/draft-ietf-manet-aodvv2/

Note that this implementation is incompatible with AODVv2 itself,
as various details are changed, and not all features have been
implemented
2016-05-29 18:35:50 +02:00
2cc4928a99 Use SLF4J for logging. 2016-05-29 18:24:36 +02:00
8bafd62e35 Simplified ConnectionHandler interface. 2016-05-10 17:19:36 +09:00
7d1f929c2c Improved readme.
- removed donate link for now
- mention internet transport
- suggest Android Studio for building
2016-04-26 12:27:11 +02:00
453b2d7fe6 Assume default port if none is specified. 2016-04-10 17:32:55 +02:00
97e70c2092 Renamed KeyServers to KeyAddresses, properly use defaults. 2016-04-07 20:39:22 +02:00
b3212bf3f4 Split server params on whitespace. 2016-04-07 17:57:54 +02:00
cfb0723c1f Merge branch 'replace-db' 2016-04-01 00:58:02 +02:00
656f52d3f3 Switch to Slick with H2 as database.
This allows us to use the same database implementation for Android
and servers.
2016-04-01 00:56:05 +02:00
5d3720b7e9 Bumped version to 0.2.3 2016-03-08 01:43:16 +01:00
127acfb3ab Use domain instead of IP for default server. 2016-03-08 01:33:44 +01:00
ca2c4c65ab Added systemd unit file to distribution. 2016-02-22 00:28:44 +01:00
07a2fde2af As server, store files in working directory instead of home folder. 2016-02-22 00:28:07 +01:00
c148ee928c Explicitly mention hop limit in protocol definition. 2016-02-20 20:49:35 +01:00
333b3495ae Added hint text for message EditText. 2016-02-18 23:32:56 +01:00
addf8e1950 Ignore sticky network intent. 2016-02-16 19:41:44 +01:00
4d6afbc9cd Limit number of internet connections, connect to random node. 2016-02-16 19:08:51 +01:00
375d245765 Added second default node.
This will only have an effect for new installs.
2016-02-16 18:23:04 +01:00
f19fc3f6d1 Externalized strings. 2016-02-11 17:46:05 +01:00
721c99c6d9 Optimized imports. 2016-02-11 17:42:32 +01:00
3fb6c009ba Disable Bluetooth on exit if it was disabled before start (fixes #15). 2016-02-11 17:36:54 +01:00
6a8a7971f8 Bumped version to 0.2.2 2016-02-11 17:09:41 +01:00
2c2bc1929d Clarified string. 2016-02-11 17:07:57 +01:00
8c7d4db8c5 Fixed unit tests. 2016-02-11 14:03:10 +01:00
021e22e50b Don't need to block explicitly on transfers. 2016-02-11 13:54:34 +01:00
ba3ce67a06 Fixed crash when receiving message with invalid length. 2016-02-11 13:51:15 +01:00
02a506608f Significantly decreased CPU usage.
Calls to InputStream#available() weren't blocking, so the loops
in both classes were running hot. Replaced the conditional with
a blocking call instead. CPU usage is down from 100% to barely
noticable.
2016-02-10 21:18:51 +01:00
b58bc9d198 Moved version values directly into gradle files for F-Droid compatiblity. 2016-02-03 14:01:16 +01:00
37d3ff4377 Bumped version to 0.2.1 2016-02-01 14:59:41 +01:00
84e00a23d2 Don't display the same message multiple times. 2016-02-01 14:54:58 +01:00
6e3cf1da63 Catch SocketException. 2016-02-01 14:35:31 +01:00
9af22b8897 Fixed potential NPE in Bluetooth interface. 2016-02-01 14:35:01 +01:00
9bb70aa405 Fixed possible NPE. 2016-02-01 13:46:39 +01:00
f3b956c89f Updated dependencies. 2016-01-27 21:23:09 +01:00
9b19158adf Show number of connected devices in notification. 2016-01-27 20:28:38 +01:00
ec6aeeb78f Added .apk files to .gitignore. 2016-01-25 22:47:20 +01:00
d2dab0121c Changed name of server package. 2016-01-25 22:46:48 +01:00
0d56efc232 Don't close app when pressing app icon in main activity. 2016-01-25 22:46:26 +01:00
5e00a2341c Bumped version to 0.2.0 2016-01-25 22:27:13 +01:00
6e12494c2c Merge branch 'internet-transport' 2016-01-25 22:19:09 +01:00
ddb3d64708 Added user option for server. 2016-01-25 22:17:56 +01:00
1df3d9a46f Changed command line formant for gradle. 2016-01-24 23:44:04 +01:00
d2f497d4de Log username and status on start. 2016-01-24 23:43:10 +01:00
6cf8a3c0b8 Allow setting status via command line. 2016-01-24 23:42:56 +01:00
dc55d74f9b Log address with username on connect if we know the name. 2016-01-24 14:03:07 +01:00
3cbba72117 Updated server address, improved documentation. 2016-01-24 13:45:43 +01:00
d3c2b5ee26 Properly check if user is a contact. 2016-01-24 13:17:02 +01:00
244fd32762 Catch InvalidKeyException when receiving messages. 2016-01-19 00:08:53 +01:00
c424bed315 Fixed potential crash. 2016-01-18 18:19:42 +01:00
ac12941ea8 Fixed service being started when it should be disabled. 2016-01-18 18:19:42 +01:00
39a2120300 Revert "Don't close app when clicking app icon in MainActivity."
This was also breaking the back button.

This reverts commit 9b5175114f.
2016-01-18 18:19:42 +01:00
514827ad8f Allow adding user by ID or QR code (fixes #36). 2016-01-18 18:19:42 +01:00
1a8ae49f79 Handle null intent in onStartCommand(). 2016-01-18 18:19:41 +01:00
b21cf17cea Make Bluetooth an optional requirement. 2016-01-18 18:19:41 +01:00
199b185861 Added server project for internet routing.
Also adjusted Log trait and visibility of library classes.
2016-01-18 18:19:41 +01:00
28cb4f15d9 Added option to start on boot (fixes #19).
This does not trigger the "enable Bluetooth" dialog, which means
Bluetooth won't be enabled. Even if Bluetooth was already active,
we might not have the necessary permissions. Internet will work
fine though.
2016-01-18 18:16:55 +01:00
6867b19380 Added floating action button to add contacts (fixes #40). 2015-12-03 20:15:26 +01:00
1942986962 Removed guava dependency. 2015-11-21 21:54:23 +01:00
49293e259e Cleaned build scripts. 2015-11-21 21:29:14 +01:00
925eb2d5c5 Use encrypt-then-mac instead of mac-and-encrypt. 2015-11-19 22:43:46 +01:00
9b5175114f Don't close app when clicking app icon in MainActivity. 2015-11-12 01:57:45 +01:00
f5ee0996eb Force close app when exception occurs in FutureHelper. 2015-10-29 14:42:31 +01:00
a951b05965 Improved documentation for Crypto class. 2015-10-18 22:40:01 +02:00
269fe41ebf Split project into seperate modules for core and android (fixes #18). 2015-10-18 22:39:51 +02:00
8c84cb2924 Updated license text for launcher icon (ref #2). 2015-09-27 16:28:57 +02:00
67091caa5d Merge pull request #39 from twzkxkan/patch-1
fix typo, clarify that messages are encrypted
2015-09-24 15:56:25 +02:00
twzkxkan
7d1beeff24 fix typo, clarify that messages are encrypted 2015-09-24 09:52:39 -04:00
86d9275dcf Added launcher icon (fixes #2). 2015-09-24 15:29:40 +02:00
90069917b0 Bumped version to 0.1.7 2015-09-23 01:07:04 +02:00
d64bf41813 Fixed username and status not being updated on other devices. 2015-09-23 01:01:25 +02:00
10066b2c83 Set input type for name and status. 2015-09-23 00:54:22 +02:00
b11bf85d90 Changed AddContactsActivity to ConnectionsActivity.
This means all neighbors are shown, not just non-contacts.

Also fixed a bug where the list would not be refreshed when
first opened.
2015-09-23 00:46:38 +02:00
123c56c322 Removed add contact menu item. 2015-09-23 00:33:45 +02:00
6042c3fb11 EnsichatActivity.service should return option. 2015-09-23 00:31:40 +02:00
e66bccc044 Show connections in actionbar. 2015-09-23 00:26:41 +02:00
87632b6225 Added parent activity reference in manifest. 2015-09-23 00:21:20 +02:00
cdcf0ace01 Remvoed count parameter from getMessagesCursor. 2015-09-19 13:42:57 +02:00
1c48484358 Moved unused method into tests. 2015-09-14 03:15:56 +02:00
185 changed files with 5335 additions and 2588 deletions

3
.gitignore vendored
View file

@ -4,4 +4,5 @@
/build
*.iml
*.iws
*.ipr
*.ipr
*.apk

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "buildSrc"]
path = buildSrc
url = https://github.com/xelnaga/gradle-android-scala-plugin.git

32
.travis.yml Normal file
View file

@ -0,0 +1,32 @@
language: android
jdk: oraclejdk8
# Install Android SDK
android:
components:
- tools
- platform-tools
- build-tools-24.0.2
- android-24
- extra-android-m2repository
# Cache gradle dependencies
# https://docs.travis-ci.com/user/languages/android/#Caching
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
cache:
directories:
- $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/
env:
- GRADLE_OPTS=-Xmx2048m
script:
# Lint fails because travis doesn't have platform-tools 24
# https://github.com/travis-ci/travis-ci/issues/6699
#- ./gradlew lint
- ./gradlew core:test
- ./gradlew server:release
- ./gradlew integration:assemble
- ./gradlew android:assembleRelRelease || ./gradlew android:assembleRelRelease

1046
LICENSE

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,9 @@ Nodes MUST NOT have a public key with the broadcast address or null
address as hash. Additionally, nodes MUST NOT connect to a node with
either address.
All integer fields are in network byte order, and unsigned (unless
specified otherwise).
Crypto
------
@ -40,22 +43,15 @@ private key, and the result written to the 'Encryption Data' part.
Routing
-------
A simple flood routing protocol is currently used. Every node forwards
all messages, unless a message with the same Origin and Sequence Number
has already been received.
The routing protocol is based on
[AODVv2](https://datatracker.ietf.org/doc/draft-ietf-manet-aodvv2/),
with various features left out.
Nodes MUST store pairs of (Origin, Sequence Number) for all received
messages. After receiving a new message, entries with the same Origin
and Sequence Number between _received_ + 1 and _received_ + 32767 MUST
be removed (with a wrap around at the maximum value). The entries MUST
NOT be cleared while the program is running. They MAY be cleared when
the program is exited.
TODO: Add Documentation for routing protocol.
There is currently no support for offline messages. If sender and
receiver are not in the same mesh, the message will not arrive.
Nodes are free implement different routing algorithms.
Messages
--------
@ -84,14 +80,12 @@ AES key is wrapped with the recipient's public RSA key.
### Header
Every message starts with one 74 byte header indicating the message
version, type and ID, followed by the length of the message. The
header is in network byte order, i.e. big endian. The header may have
6 bytes of additional data.
version, type and ID, followed by the length of the message.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version | Protocol-Type | Hop Limit | Hop Count |
| Version | Protocol-Type | Tokens | Hop Count |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
@ -117,8 +111,8 @@ where such a packet came from MAY be closed.
Protocol-Type is one of those specified in section Protocol Messages,
or 255 for Content Messages.
Hop Limit SHOULD be set to `MAX_HOP_COUNT` on message creation, and
MUST NOT be changed by a forwarding node.
Tokens is the number of times this message should be copied to
different relays.
Hop Count specifies the number of nodes a message may pass. When
creating a package, it is initialized to 0. Whenever a node forwards
@ -220,6 +214,100 @@ After this message has been received, communication with normal messages
may start.
### Route Request (Protocol-Type = 2)
Sent to request a route to a specific Target Address.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Address (32 bytes) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| OrigSeqNum | OriginMetric |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TargMetric |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Equivalent to the Sequence Number in the message header.
Set OrigMetric = RouterClient.Cost for the Router Client entry
which includes OrigAddr.
If an Invalid route exists in the Local Route Set matching
TargAddr using longest prefix matching and has a valid
sequence number, set TargSeqNum = LocalRoute.SeqNum.
Otherwise, set TargSeqNum = -1. This field is signed.
### Route Reply (Protocol-Type = 3)
Sent as a reply when a Route Request arrives, to inform other nodes
about a route.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TargSeqNum | TargMetric |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Set TargMetric = RouterClient.Cost for the Router Client entry
which includes TargAddr.
### Route Error (Protocol-Type = 4)
Notifies other nodes of routes that are no longer available. The target
address MUST be set to the broadcast address.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Packet Source (32 bytes) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Address (32 bytes) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SeqNum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Packet Source is the source address of the message triggering this
Route Error. If the route error is not triggered by a message,
this MUST be set to the null address.
Address is the address that is no longer reachable.
SeqNum is the sequence number of the route that is no longer available
(if known). Otherwise, set TargSeqNum = -1. This field is signed.
### PublicKeyRequest (Protocol-Type = 5)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Contains an address for which the sender wants the corresponding public
key.
### PublicKeyReply (Protocol-Type = 6)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Key Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ /
\ Key (variable length) \
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Contains a node's public key in binary form. Sent in reply to a
PublicKeyRequest.
Content Messages
----------------
@ -265,3 +353,14 @@ Text the string to be transferred, encoded as UTF-8.
Contains the sender's name and status, which should be used for
display to users.
### MessageReceived (Content-Type = 8)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Message ID |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Confirms that a previous content message has been received by the
target node. Message ID is the ID of that message.

View file

@ -1,11 +1,24 @@
PROJECT DISCONTINUED
====================
Unfortunately, I won't be able to continue development on Ensichat, due to lack of time. I suggest
you give [Briar](https://briarproject.org/) a try instead.
If you wish to take over maintenance of the project, please contact me.
Ensichat
========
[![BitCoin donate button](https://img.shields.io/badge/bitcoin-donate-yellow.svg)](https://blockchain.info/address/1DmU6QVGSKXGXJU1bqmmStPDNsNnYoMJB4)
[![Build Status](https://travis-ci.org/Nutomic/ensichat.svg?branch=master)](https://travis-ci.org/Nutomic/ensichat)
[![License: MPLv2](https://img.shields.io/badge/License-MPLv2-blue.svg)](https://opensource.org/licenses/MPL-2.0)
Instant messanger for Android that is fully decentralized. Messages are sent directly between
devices via Bluetooth, without any central server. A simple flood-based routing is used for
message propagation.
Instant messenger for Android that is fully decentralized, and uses strong end-to-end
encryption. Messages are sent directly between devices via Bluetooth or Internet, without any
central server. Relay nodes are used to ensure message delivery, even if the target node is
offline.
For details on how Ensichat works, you can check out my [bachelor thesis](docs/bachelor-thesis.pdf), and
read the [protocol definition](PROTOCOL.md).
<img src="graphics/screenshot_phone_1.png" alt="screenshot 1" width="200" />
<img src="graphics/screenshot_phone_2.png" alt="screenshot 2" width="200" />
@ -13,14 +26,31 @@ message propagation.
[![Get it on Google Play](https://developer.android.com/images/brand/en_generic_rgb_wo_60.png)](https://play.google.com/store/apps/details?id=com.nutomic.ensichat) [![Get it on F-Droid](https://f-droid.org/wiki/images/0/06/F-Droid-button_get-it-on.png)](https://f-droid.org/repository/browse/?fdid=com.nutomic.ensichat)
To set up a server, please follow the [instructions on the wiki](https://github.com/Nutomic/ensichat/wiki/Running-your-own-server).
Building
--------
To create a debug apk, run `./gradlew assembleDevDebug`. This requires at least Android Lollipop on your development device. If you don't have Lollipop, you can alternatively use `./gradlew assembleRelDebug`. However, this results in considerably slower incremental builds
To setup a development environment, just install [Android Studio](https://developer.android.com/sdk/)
and import the project.
To create a release apk, run `./gradlew assembleRelRelease`.
Alternatively, you can use the command line. To create a debug apk, run `./gradlew assembleDevDebug`.
This requires at least Android Lollipop on your development device. If you don't have 5.0 or higher,
you have to use `./gradlew assembleRelDebug`. However, this results in considerably slower
incremental builds. To create a release apk, run `./gradlew assembleRelRelease`.
Testing
-------
You can run the unit tests with `./gradlew test`. After connecting an Android device, you can run
the Android tests with `./gradlew connectedDevDebugAndroidTest` (or
`./gradlew connectedRelDebugAndroidTest` if your Android version is lower than 5.0).
To run integration tests for the core module, use `./gradlew integration:run`. If this fails (or
is very slow), try changing the value of Crypto#PublicKeySize to 512 (in the core module).
License
-------
The project is licensed under the [MPLv2](LICENSE).
All code is licensed under the [GPL](LICENSE), v3 or later.
The launcher icon is based on the [Bubbles Icon](https://www.iconfinder.com/icons/285667/bubbles_icon) created by [Paomedia](https://www.iconfinder.com/paomedia) which is available under [CC BY 3.0](http://creativecommons.org/licenses/by/3.0/).

View file

@ -1,23 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'jp.leafytree.android-scala'
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath "jp.leafytree.gradle:gradle-android-scala-plugin:1.4"
}
}
dependencies {
compile "com.android.support:appcompat-v7:23.0.0"
compile 'com.android.support:design:24.2.1'
compile 'com.android.support:multidex:1.0.1'
androidTestCompile "com.android.support:multidex-instrumentation:1.0.1",
{ exclude module: "multidex" }
compile "org.scala-lang:scala-library:2.11.7"
compile 'com.google.guava:guava:18.0'
compile 'org.scala-lang:scala-library:2.11.7'
compile 'com.mobsandgeeks:adapter-kit:0.5.3'
compile 'com.google.zxing:android-integration:3.3.0'
compile 'com.google.zxing:core:3.3.0'
compile 'org.slf4j:slf4j-android:1.7.21'
compile project(path: ':core')
androidTestCompile 'com.android.support:multidex-instrumentation:1.0.1',
{ exclude module: 'multidex' }
}
// RtlHardcoded behaviour differs between target API versions. We only care about API 15.
@ -31,21 +25,23 @@ preBuild.doFirst {
}
android {
compileSdkVersion 23
buildToolsVersion "22.0.1"
compileSdkVersion 24
buildToolsVersion "24.0.2"
defaultConfig {
applicationId "com.nutomic.ensichat"
targetSdkVersion 23
versionCode 7
versionName "0.1.6"
targetSdkVersion 24
versionCode 17
versionName "0.5.2"
multiDexEnabled true
testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner"
}
buildTypes.debug {
applicationIdSuffix ".debug"
testCoverageEnabled true
buildTypes {
debug {
applicationIdSuffix ".debug"
testCoverageEnabled true
}
}
// Increasing minSdkVersion reduces compilation time for MultiDex.

View file

@ -6,15 +6,18 @@ import android.test.AndroidTestCase
class BluetoothInterfaceTest extends AndroidTestCase {
private lazy val adapter = new BluetoothInterface(getContext, new Handler(), Message => Unit,
() => Unit, Message => false)
private lazy val btInterface = new BluetoothInterface(getContext, new Handler(), null)
/**
* Test for issue [[https://github.com/Nutomic/ensichat/issues/3 #3]].
*/
def testStartBluetoothOff(): Unit = {
BluetoothAdapter.getDefaultAdapter.disable()
adapter.create()
val btAdapter = BluetoothAdapter.getDefaultAdapter
if (btAdapter == null)
return
btAdapter.disable()
btInterface.create()
}
}

View file

@ -6,11 +6,15 @@
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<application
android:name="android.support.multidex.MultiDexApplication"
android:name=".App"
android:allowBackup="true"
android:fullBackupContent="true"
android:icon="@drawable/ic_launcher"
@ -30,11 +34,13 @@
<activity
android:name=".activities.MainActivity"
android:label="@string/app_name"
android:launchMode="singleTop" />
android:launchMode="singleTop"
android:theme="@style/AppTheme.NoActionBar"/>
<activity
android:name=".activities.AddContactsActivity"
android:label="@string/add_contacts" >
android:name=".activities.ConnectionsActivity"
android:label="@string/connections"
android:parentActivityName=".activities.MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.MainActivity" />
@ -42,13 +48,20 @@
<activity
android:name=".activities.SettingsActivity"
android:label="@string/settings" >
android:label="@string/settings"
android:parentActivityName=".activities.MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.MainActivity" />
</activity>
<service android:name=".protocol.ChatService" />
<receiver android:name=".service.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<service android:name=".service.ChatService" />
</application>

View file

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

View file

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

View file

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

View file

Before

Width:  |  Height:  |  Size: 759 B

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

View file

@ -26,7 +26,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/devices_empty"
android:text="@string/no_connections"
android:gravity="center" />
</LinearLayout>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/primary"
android:layout_width="fill_parent"

View file

@ -1,12 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="10"
android:orientation="vertical"
android:background="@color/chat_background">
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_height="?attr/actionBarSize"
android:layout_width="match_parent"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</android.support.design.widget.AppBarLayout>
<FrameLayout
android:layout_height="0dp"
android:layout_width="match_parent"
@ -41,6 +57,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/message_hint"
android:textColorHint="#cccccc"
android:imeOptions="actionDone"
android:inputType="textMultiLine|textCapSentences|textAutoCorrect"/>

View file

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_height="?attr/actionBarSize"
android:layout_width="match_parent"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
android:contentInsetLeft="0dp"
android:contentInsetStart="0dp"
app:contentInsetLeft="0dp"
app:contentInsetStart="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/title_holder"
android:orientation="vertical"
android:clickable="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:gravity="center_vertical"
android:paddingLeft="10dp"
android:paddingStart="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/title"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/subtitle"
android:textSize="16sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
</android.support.v7.widget.Toolbar>
</android.support.design.widget.AppBarLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ListView
android:id="@android:id/list"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
<TextView
android:id="@android:id/empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_contacts_found"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true" />
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
android:layout_margin="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_add_white_24dp"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true" />
</RelativeLayout>
</LinearLayout>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:padding="25dp">
<ImageView
android:id="@+id/identicon"
android:layout_width="150dp"
android:layout_height="150dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:layout_marginBottom="25dp" />
<ImageView
android:id="@+id/qr_code"
android:layout_width="150dp"
android:layout_height="150dp"
android:scaleType="fitCenter" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/add_contact"
android:title="@string/add_contact"
android:icon="@drawable/ic_person_add_white_24dp"
app:showAsAction="ifRoom" />
<item
android:id="@+id/scan_qr"
android:title="@string/scan_qr_code"
android:icon="@drawable/ic_qrcode_white_24dp"
app:showAsAction="ifRoom" />
</menu>

View file

@ -1,13 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/add_contact"
android:title="@string/add_contacts"
android:icon="@drawable/ic_action_add_person"
app:showAsAction="ifRoom" />
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/share_app"
@ -21,8 +14,4 @@
android:id="@+id/settings"
android:title="@string/settings" />
<item
android:id="@+id/exit"
android:title="@string/exit" />
</menu>

View file

@ -14,4 +14,8 @@
<!-- Background color for ChatFragment -->
<color name="chat_background">#f3f2fb</color>
<color name="title_connections_error">#F44336</color>
<color name="title_connections_warning">#FFEB3B</color>
<color name="title_connections_ok">#8BC34A</color>
</resources>

View file

@ -18,32 +18,42 @@
<!-- Label for button to finish activity -->
<string name="done">Done</string>
<!-- Toast shown if location permission is not granted -->
<string name="toast_location_required">Location permission is required to scan for other Bluetooth devices</string>
<!-- MainActivity -->
<!-- Toast shown if user denies request to enable bluetooth -->
<string name="bluetooth_required">Bluetooth is required for this app.</string>
<string name="toast_bluetooth_denied">Please enable Bluetooth to connect with devices near you</string>
<!-- ContactsFragment -->
<!-- Number of connections shown in actionbar -->
<plurals name="title_connections">
<item quantity="one">1 Connection</item>
<item quantity="other">%s Connections</item>
</plurals>
<!-- Empty text for contacts list -->
<string name="no_contacts_found">You haven\'t added any contacts yet</string>
<!-- Menu item to share this app's apk -->
<string name="share_app">Share App</string>
<!-- Menu item to close app and stop service -->
<string name="exit">Exit</string>
<!-- ChatFragment -->
<!-- Hint text for the message EditText -->
<string name="message_hint">Type a message</string>
<!-- AddContactsActivity -->
<!-- ConnectionsActivity -->
<!-- Activity title -->
<string name="add_contacts">Add Contacts</string>
<string name="connections">Connections</string>
<!-- Empty text for devices list -->
<string name="devices_empty">Searching for Users\nRange: ~10m</string>
<string name="no_connections">Searching for Users\nRange: ~10m</string>
<!-- Alertdialog message to add new contact -->
<string name="dialog_add_contact">Do you want to add %1$s as contact?</string>
@ -51,6 +61,17 @@
<!-- Toast shown after contact has been added -->
<string name="toast_contact_added">Contact added</string>
<!-- Toast shown when clicking a user that is already a contact -->
<string name="contact_already_added">You have already added %1$s as a contact</string>
<string name="enter_id">Enter user ID</string>
<string name="invalid_address">Invalid address</string>
<string name="add_contact">Add Contact</string>
<string name="scan_qr_code">Scan QR-Code</string>
<!-- SettingsActivity -->
@ -72,8 +93,8 @@
<!-- Preference title -->
<string name="notification_sounds">Notification Sounds</string>
<!-- Preference title (debug only)-->
<string name="max_connections" translatable="false">Maximum Number of Connections</string>
<!-- Preference title -->
<string name="servers">Servers</string>
<!-- Preference title -->
<string name="report_issue">Report Issue</string>
@ -86,15 +107,26 @@
<!-- Preference title -->
<string name="version">Version</string>
<!-- IdenticonFragment -->
<!-- UserInfoFragment -->
<!-- Device address label and value -->
<string name="address_colon">Address: %1$s</string>
<string name="ensichat_user_address">Ensichat User Address</string>
<string name="address_copied_to_clipboard">Address copied to clipboard</string>
<!-- ChatService -->
<!-- Notification text for incoming message -->
<string name="notification_message">New message!</string>
<!-- Info text for persistent notification -->
<plurals name="notification_connections">
<item quantity="one">Connected to %1$s device</item>
<item quantity="other">Connected to %1$s devices</item>
</plurals>
</resources>

View file

@ -9,6 +9,11 @@
<item name="android:alertDialogTheme">@style/AlertDialogTheme</item>
</style>
<style name="AppTheme.NoActionBar" parent="AppTheme">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="AlertDialogTheme" parent="@style/Theme.AppCompat.Light.Dialog">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>

View file

@ -4,30 +4,27 @@
<EditTextPreference
android:title="@string/user_name"
android:key="user_name" />
android:key="user_name"
android:inputType="textCapWords"/>
<EditTextPreference
android:title="@string/user_status"
android:key="user_status" />
android:key="user_status"
android:inputType="textCapSentences" />
<CheckBoxPreference
android:title="@string/notification_sounds"
android:key="notification_sounds"
android:defaultValue="@bool/default_notification_sounds" />
android:key="notification_sounds" />
<EditTextPreference
android:title="@string/scan_interval_seconds"
android:key="scan_interval_seconds"
android:defaultValue="@string/default_scan_interval"
android:inputType="number"
android:numeric="integer" />
<EditTextPreference
android:title="@string/max_connections"
android:key="max_connections"
android:defaultValue="@string/default_max_connections"
android:inputType="number"
android:numeric="integer" />
android:title="@string/servers"
android:key="servers" />
<Preference
android:title="@string/report_issue"

View file

@ -0,0 +1,13 @@
package com.nutomic.ensichat
import android.support.multidex.MultiDexApplication
import com.nutomic.ensichat.util.PRNGFixes
class App extends MultiDexApplication {
override def onCreate(): Unit = {
super.onCreate()
PRNGFixes.apply()
}
}

View file

@ -0,0 +1,156 @@
package com.nutomic.ensichat.activities
import android.app.Activity
import android.app.AlertDialog.Builder
import android.content.DialogInterface.OnClickListener
import android.content._
import android.os.Bundle
import android.support.v4.app.NavUtils
import android.support.v4.content.LocalBroadcastManager
import android.view._
import android.widget.AdapterView.OnItemClickListener
import android.widget._
import com.google.zxing.integration.android.IntentIntegrator
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.service.CallbackHandler
import com.nutomic.ensichat.views.UsersAdapter
/**
* Lists all nearby, connected devices and allows adding them to be added as contacts.
*/
class ConnectionsActivity extends EnsichatActivity with OnItemClickListener {
private lazy val adapter = new UsersAdapter(this)
/**
* Initializes layout, registers connection and message listeners.
*/
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
getSupportActionBar.setDisplayHomeAsUpEnabled(true)
setContentView(R.layout.activity_connections)
val list = findViewById(android.R.id.list).asInstanceOf[ListView]
list.setAdapter(adapter)
list.setOnItemClickListener(this)
list.setEmptyView(findViewById(android.R.id.empty))
val filter = new IntentFilter()
filter.addAction(CallbackHandler.ActionConnectionsChanged)
filter.addAction(CallbackHandler.ActionContactsUpdated)
LocalBroadcastManager.getInstance(this)
.registerReceiver(onContactsUpdatedReceiver, filter)
}
override def onResume(): Unit = {
super.onResume()
runOnServiceConnected(() => {
updateConnections()
})
}
override def onDestroy(): Unit = {
super.onDestroy()
LocalBroadcastManager.getInstance(this).unregisterReceiver(onContactsUpdatedReceiver)
}
override def onCreateOptionsMenu(menu: Menu): Boolean = {
getMenuInflater.inflate(R.menu.connections, menu)
true
}
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case R.id.add_contact =>
val et = new EditText(this)
new Builder(this)
.setTitle(R.string.enter_id)
.setView(et)
.setPositiveButton(android.R.string.ok, new OnClickListener {
override def onClick(dialog: DialogInterface, which: Int): Unit = {
addContact(et.getText.toString)
}
})
.setNegativeButton(android.R.string.cancel, null)
.show()
true
case R.id.scan_qr =>
new IntentIntegrator(this).initiateScan
true
case android.R.id.home =>
NavUtils.navigateUpFromSameTask(this)
true
case _ =>
super.onOptionsItemSelected(item)
}
/**
* Initiates adding the device as contact if it hasn't been added yet.
*/
override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit =
addContact(adapter.getItem(position).address.toString)
/**
* Receives value of scanned QR code and sets it as device ID.
*/
override def onActivityResult(requestCode: Int, resultCode: Int, intent: Intent) {
val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent)
if (scanResult != null && resultCode == Activity.RESULT_OK) {
addContact(scanResult.getContents)
}
}
/**
* Parses the address, and shows a dialog to add the user as a contact.
*
* Displays a warning toast if the address is invalid or if the user is already a contact.
*/
private def addContact(address: String): Unit = {
val parsedAddress =
try {
new Address(address)
} catch {
case e: IllegalArgumentException =>
Toast.makeText(this, R.string.invalid_address, Toast.LENGTH_LONG).show()
return
}
val user = service.get.getUser(parsedAddress)
if (database.get.getContacts.map(_.address).contains(user.address)) {
val text = getString(R.string.contact_already_added, user.name)
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
return
}
new Builder(this)
.setMessage(getString(R.string.dialog_add_contact, user.name))
.setPositiveButton(android.R.string.yes, new OnClickListener {
override def onClick(dialog: DialogInterface, which: Int): Unit = {
service.get.addContact(user)
Toast.makeText(ConnectionsActivity.this, R.string.toast_contact_added, Toast.LENGTH_SHORT)
.show()
}
})
.setNegativeButton(android.R.string.no, null)
.show()
}
/**
* Fetches connections and displays them (excluding contacts).
*/
private val onContactsUpdatedReceiver = new BroadcastReceiver() {
override def onReceive(context: Context, intent: Intent): Unit = {
runOnUiThread(new Runnable {
override def run(): Unit = updateConnections()
})
}
}
private def updateConnections(): Unit = {
adapter.clear()
service.get.connections().map(a => service.get.getUser(a))
.foreach(adapter.add)
}
}

View file

@ -3,7 +3,7 @@ package com.nutomic.ensichat.activities
import android.content.{ComponentName, Context, Intent, ServiceConnection}
import android.os.{Bundle, IBinder}
import android.support.v7.app.AppCompatActivity
import com.nutomic.ensichat.protocol.{ChatService, ChatServiceBinder}
import com.nutomic.ensichat.service.ChatService
/**
* Connects to [[ChatService]] and provides access to it.
@ -37,7 +37,7 @@ class EnsichatActivity extends AppCompatActivity with ServiceConnection {
* Clears the list containing them.
*/
override def onServiceConnected(componentName: ComponentName, iBinder: IBinder): Unit = {
val binder = iBinder.asInstanceOf[ChatServiceBinder]
val binder = iBinder.asInstanceOf[ChatService.Binder]
chatService = Option(binder.service)
listeners.foreach(_())
listeners = Set.empty
@ -58,8 +58,10 @@ class EnsichatActivity extends AppCompatActivity with ServiceConnection {
/**
* Returns the [[ChatService]].
*
* Should only be called after [[runOnServiceConnected]] callback was called.
* Will only be set after [[runOnServiceConnected]].
*/
def service = chatService.get
def service = chatService.map(_.getConnectionHandler)
def database = chatService.map(_.database)
}

View file

@ -1,17 +1,22 @@
package com.nutomic.ensichat.activities
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.content.pm.PackageManager
import android.content.{Context, Intent}
import android.os.Bundle
import android.preference.PreferenceManager
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import android.support.v7.app.AppCompatActivity
import android.view.View.OnClickListener
import android.view.inputmethod.{EditorInfo, InputMethodManager}
import android.view.{KeyEvent, View}
import android.widget.TextView.OnEditorActionListener
import android.widget.{Button, EditText, TextView}
import android.widget.{Button, EditText, TextView, Toast}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.fragments.SettingsFragment
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import com.nutomic.ensichat.core.interfaces.SettingsInterface._
/**
* Shown on first start, lets the user enter their name.
@ -19,6 +24,7 @@ import com.nutomic.ensichat.fragments.SettingsFragment
class FirstStartActivity extends AppCompatActivity with OnEditorActionListener with OnClickListener {
private val KeyIsFirstStart = "first_start"
private val RequestLocationPermission = 127
private lazy val preferences = PreferenceManager.getDefaultSharedPreferences(this)
private lazy val imm = getSystemService(Context.INPUT_METHOD_SERVICE)
@ -30,19 +36,24 @@ class FirstStartActivity extends AppCompatActivity with OnEditorActionListener w
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
if (!preferences.getBoolean(KeyIsFirstStart, true)) {
startMainActivity()
return
}
setContentView(R.layout.activity_first_start)
setTitle(R.string.welcome)
username.setText(BluetoothAdapter.getDefaultAdapter.getName.trim)
val name = Option(BluetoothAdapter.getDefaultAdapter).map(_.getName.trim).getOrElse("")
username.setText(name)
username.setOnEditorActionListener(this)
done.setOnClickListener(this)
imm.showSoftInput(username, InputMethodManager.SHOW_IMPLICIT)
val permission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
if (preferences.getBoolean(KeyIsFirstStart, true)) {
imm.showSoftInput(username, InputMethodManager.SHOW_IMPLICIT)
}
else if (permission != PackageManager.PERMISSION_GRANTED) {
requestLocationPermission()
}
else {
startMainActivity()
}
}
/**
@ -60,7 +71,7 @@ class FirstStartActivity extends AppCompatActivity with OnEditorActionListener w
override def onClick(v: View): Unit = save()
/**
* Saves values and calls [[startMainActivity]].
* Saves username and default settings values, then calls [[startMainActivity]].
*/
private def save(): Unit = {
imm.hideSoftInputFromWindow(username.getWindowToken, 0)
@ -68,11 +79,27 @@ class FirstStartActivity extends AppCompatActivity with OnEditorActionListener w
preferences
.edit()
.putBoolean(KeyIsFirstStart, false)
.putString(SettingsFragment.KeyUserName, username.getText.toString.trim)
.putString(SettingsFragment.KeyUserStatus, getString(R.string.default_user_status))
.putString(KeyUserName, username.getText.toString.trim)
.putString(KeyUserStatus, SettingsInterface.DefaultUserStatus)
.putBoolean(KeyNotificationSoundsOn, DefaultNotificationSoundsOn)
.putString(KeyScanInterval, DefaultScanInterval.toString)
.putString(KeyAddresses, DefaultAddresses)
.apply()
startMainActivity()
requestLocationPermission()
}
private def requestLocationPermission(): Unit =
ActivityCompat.requestPermissions(this, Array(Manifest.permission.ACCESS_COARSE_LOCATION), RequestLocationPermission)
override def onRequestPermissionsResult(requestCode: Int,
permissions: Array[String], grantResults: Array[Int]): Unit = requestCode match {
case RequestLocationPermission =>
if (grantResults.length > 0 && grantResults(0) == PackageManager.PERMISSION_GRANTED) {
startMainActivity()
} else {
Toast.makeText(this, R.string.toast_location_required, Toast.LENGTH_SHORT).show()
}
}
def startMainActivity(): Unit = {

View file

@ -4,11 +4,12 @@ import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content._
import android.os.Bundle
import android.preference.PreferenceManager
import android.view.MenuItem
import android.widget.Toast
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.fragments.{ChatFragment, ContactsFragment}
import com.nutomic.ensichat.protocol.Address
object MainActivity {
@ -24,6 +25,8 @@ object MainActivity {
val ExtraAddress = "address"
val PrefWasBluetoothEnabled = "was_bluetooth_enabled"
}
/**
@ -44,7 +47,14 @@ class MainActivity extends EnsichatActivity {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (getIntent.getAction == MainActivity.ActionRequestBluetooth) {
if (getIntent.getAction == MainActivity.ActionRequestBluetooth &&
Option(BluetoothAdapter.getDefaultAdapter).isDefined) {
val btAdapter = BluetoothAdapter.getDefaultAdapter
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putBoolean(MainActivity.PrefWasBluetoothEnabled, btAdapter.isEnabled)
.apply()
val intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 0)
startActivityForResult(intent, RequestSetDiscoverable)
@ -89,8 +99,7 @@ class MainActivity extends EnsichatActivity {
requestCode match {
case RequestSetDiscoverable =>
if (resultCode == Activity.RESULT_CANCELED) {
Toast.makeText(this, R.string.bluetooth_required, Toast.LENGTH_LONG).show()
finish()
Toast.makeText(this, R.string.toast_bluetooth_denied, Toast.LENGTH_LONG).show()
}
}
@ -104,7 +113,7 @@ class MainActivity extends EnsichatActivity {
.detach(contactsFragment)
.add(android.R.id.content, new ChatFragment(address))
.commit()
getSupportActionBar.setDisplayHomeAsUpEnabled(true)
Option(getSupportActionBar).foreach(_.setDisplayHomeAsUpEnabled(true))
}
/**
@ -126,7 +135,8 @@ class MainActivity extends EnsichatActivity {
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case android.R.id.home =>
onBackPressed()
if (currentChat.isDefined)
onBackPressed()
true;
case _ =>
super.onOptionsItemSelected(item);

View file

@ -8,22 +8,30 @@ import android.util.Log
/**
* Attempts to connect to another device and calls [[onConnected]] on success.
*/
class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Unit) extends Thread {
class BluetoothConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Unit) extends Thread {
private val Tag = "ConnectThread"
private val socket =
device.btDevice.get.createInsecureRfcommSocketToServiceRecord(BluetoothInterface.AppUuid)
private val socket = try {
Option(device.btDevice.get.createInsecureRfcommSocketToServiceRecord(BluetoothInterface.AppUuid))
} catch {
case e: IOException =>
Log.w(Tag, "Failed to open Bluetooth connection", e)
None
}
override def run(): Unit = {
if (socket.isEmpty)
return
Log.i(Tag, "Connecting to " + device.toString)
try {
socket.connect()
socket.get.connect()
} catch {
case e: IOException =>
Log.v(Tag, "Failed to connect to " + device.toString, e)
try {
socket.close()
socket.get.close()
} catch {
case e2: IOException =>
Log.e(Tag, "Failed to close socket", e2)
@ -32,7 +40,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
}
Log.i(Tag, "Successfully connected to device " + device.name)
onConnected(new Device(device.btDevice.get, true), socket)
onConnected(new Device(device.btDevice.get, true), socket.get)
}
}

View file

@ -7,12 +7,14 @@ import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.os.Handler
import android.preference.PreferenceManager
import android.util.Log
import com.google.common.collect.HashBiMap
import com.nutomic.ensichat.R
import com.nutomic.ensichat.fragments.SettingsFragment
import com.nutomic.ensichat.protocol.ChatService.InterfaceHandler
import com.nutomic.ensichat.protocol._
import com.nutomic.ensichat.protocol.body.ConnectionInfo
import com.nutomic.ensichat.core.interfaces.{SettingsInterface, TransmissionInterface}
import com.nutomic.ensichat.core.messages.Message
import com.nutomic.ensichat.core.messages.body.ConnectionInfo
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.core.ConnectionHandler
import com.nutomic.ensichat.service.ChatService
import org.joda.time.{DateTime, Duration}
import scala.collection.immutable.HashMap
@ -29,27 +31,25 @@ object BluetoothInterface {
* Handles all Bluetooth connectivity.
*/
class BluetoothInterface(context: Context, mainHandler: Handler,
onMessageReceived: Message => Unit, callConnectionListeners: () => Unit,
onConnectionOpened: (Message) => Boolean)
extends InterfaceHandler {
connectionHandler: ConnectionHandler) extends TransmissionInterface {
private val Tag = "BluetoothInterface"
private lazy val btAdapter = BluetoothAdapter.getDefaultAdapter
private lazy val crypto = new Crypto(context)
private lazy val crypto = ChatService.newCrypto(context)
private var devices = new HashMap[Device.ID, Device]()
private var connections = new HashMap[Device.ID, TransferThread]()
private var connections = new HashMap[Device.ID, BluetoothTransferThread]()
private var listenThread: Option[ListenThread] = None
private var listenThread: Option[BluetoothListenThread] = None
private var cancelDiscovery = false
private var discovered = Set[Device]()
private val addressDeviceMap = HashBiMap.create[Address, Device.ID]()
private var addressDeviceMap = new HashMap[Address, Device.ID]()
/**
* Initializes and starts discovery and listening.
@ -71,7 +71,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
* Stops discovery and listening.
*/
override def destroy(): Unit = {
listenThread.get.cancel()
listenThread.foreach(_.cancel())
listenThread = None
cancelDiscovery = true
try {
@ -89,7 +89,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
* Starts discovery and listening.
*/
private def startBluetoothConnections(): Unit = {
listenThread = Some(new ListenThread(context.getString(R.string.app_name), btAdapter, connectionOpened))
listenThread = Some(new BluetoothListenThread(context.getString(R.string.app_name), btAdapter, connectionOpened))
listenThread.get.start()
cancelDiscovery = false
discover()
@ -108,8 +108,8 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
}
val pm = PreferenceManager.getDefaultSharedPreferences(context)
val scanInterval = pm.getString(SettingsFragment.KeyScanInterval,
context.getResources.getString(R.string.default_scan_interval)).toInt * 1000
val scanInterval =
pm.getString(SettingsInterface.KeyScanInterval, SettingsInterface.DefaultScanInterval.toString).toInt * 1000
mainHandler.postDelayed(new Runnable {
override def run(): Unit = discover()
}, scanInterval)
@ -131,7 +131,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
override def onReceive(context: Context, intent: Intent): Unit = {
discovered.filterNot(d => connections.keySet.contains(d.id))
.foreach { d =>
new ConnectThread(d, connectionOpened).start()
new BluetoothConnectThread(d, connectionOpened).start()
devices += (d.id -> d)
}
discovered = Set[Device]()
@ -165,18 +165,20 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
def connectionOpened(device: Device, socket: BluetoothSocket): Unit = {
devices += (device.id -> device)
connections += (device.id ->
new TransferThread(context, device, socket, this, crypto, onReceiveMessage))
new BluetoothTransferThread(context, device, socket, this, crypto, onReceiveMessage))
connections(device.id).start()
}
/**
* Removes device from active connections.
*/
def onConnectionClosed(device: Device, socket: BluetoothSocket): Unit = {
devices -= device.id
connections -= device.id
callConnectionListeners()
addressDeviceMap.inverse().remove(device.id)
def onConnectionClosed(connectionOpened: DateTime, deviceId: Device.ID): Unit = {
val address = getAddressForDevice(deviceId)
devices -= deviceId
connections -= deviceId
addressDeviceMap = addressDeviceMap.filterNot(_._2 == deviceId)
val connectionDuration = new Duration(connectionOpened, DateTime.now)
connectionHandler.onConnectionClosed(address, connectionDuration)
}
/**
@ -191,23 +193,31 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
case info: ConnectionInfo =>
val address = crypto.calculateAddress(info.key)
// Service.onConnectionOpened sends message, so mapping already needs to be in place.
addressDeviceMap.put(address, device)
if (!onConnectionOpened(msg))
addressDeviceMap.remove(address)
addressDeviceMap += (address -> device)
if (!connectionHandler.onConnectionOpened(msg))
addressDeviceMap -= address
case _ =>
onMessageReceived(msg)
connectionHandler.onMessageReceived(msg, getAddressForDevice(device))
}
private def getAddressForDevice(device: Device.ID) =
addressDeviceMap.find(_._2 == device).get._1
/**
* Sends the message to nextHop.
*/
override def send(nextHop: Address, msg: Message): Unit =
connections.get(addressDeviceMap.get(nextHop)).foreach(_.send(msg))
override def send(nextHop: Address, msg: Message): Unit = {
addressDeviceMap
.find(_._1 == nextHop || Address.Broadcast == nextHop)
.map(i => connections.get(i._2))
.getOrElse(None)
.foreach(_.send(msg))
}
/**
* Returns all active Bluetooth connections.
*/
def getConnections: Set[Address] =
connections.map(x => addressDeviceMap.inverse().get(x._1)).toSet
override def getConnections: Set[Address] =
connections.map( c => getAddressForDevice(c._1)).toSet
}

View file

@ -10,7 +10,7 @@ import android.util.Log
*
* @param name Service name to broadcast.
*/
class ListenThread(name: String, adapter: BluetoothAdapter,
class BluetoothListenThread(name: String, adapter: BluetoothAdapter,
onConnected: (Device, BluetoothSocket) => Unit) extends Thread {
private val Tag = "ListenThread"

View file

@ -3,12 +3,16 @@ package com.nutomic.ensichat.bluetooth
import java.io._
import android.bluetooth.{BluetoothDevice, BluetoothSocket}
import android.content.{IntentFilter, Intent, Context, BroadcastReceiver}
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.util.Log
import com.nutomic.ensichat.protocol._
import com.nutomic.ensichat.protocol.body.ConnectionInfo
import com.nutomic.ensichat.protocol.header.MessageHeader
import com.nutomic.ensichat.core.messages.Message
import Message.ReadMessageException
import com.nutomic.ensichat.core.messages.Message
import com.nutomic.ensichat.core.messages.body.ConnectionInfo
import com.nutomic.ensichat.core.messages.header.MessageHeader
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.core.util.Crypto
import org.joda.time.DateTime
/**
* Transfers data between connnected devices.
@ -17,8 +21,11 @@ import Message.ReadMessageException
* @param socket An open socket to the given device.
* @param onReceive Called when a message was received from the other device.
*/
class TransferThread(context: Context, device: Device, socket: BluetoothSocket, handler: BluetoothInterface,
crypto: Crypto, onReceive: (Message, Device.ID) => Unit) extends Thread {
class BluetoothTransferThread(context: Context, device: Device, socket: BluetoothSocket,
handler: BluetoothInterface, crypto: Crypto,
onReceive: (Message, Device.ID) => Unit) extends Thread {
private val connectionOpened = DateTime.now
private val Tag = "TransferThread"
@ -30,6 +37,7 @@ class TransferThread(context: Context, device: Device, socket: BluetoothSocket,
} catch {
case e: IOException =>
Log.e(Tag, "Failed to open stream", e)
close()
null
}
@ -39,6 +47,7 @@ class TransferThread(context: Context, device: Device, socket: BluetoothSocket,
} catch {
case e: IOException =>
Log.e(Tag, "Failed to open stream", e)
close()
null
}
@ -59,16 +68,14 @@ class TransferThread(context: Context, device: Device, socket: BluetoothSocket,
new IntentFilter(BluetoothDevice.ACTION_ACL_DISCONNECTED))
send(crypto.sign(new Message(new MessageHeader(ConnectionInfo.Type,
Address.Null, Address.Null, 0), new ConnectionInfo(crypto.getLocalPublicKey))))
Address.Null, Address.Null, 0, 0), new ConnectionInfo(crypto.getLocalPublicKey))))
while (socket.isConnected) {
try {
if (inStream.available() > 0) {
val msg = Message.read(inStream)
val msg = Message.read(inStream)
Log.v(Tag, "Received " + msg)
onReceive(msg, device.id)
Log.v(Tag, "Receiving " + msg)
}
onReceive(msg, device.id)
} catch {
case e @ (_: ReadMessageException | _: IOException) =>
Log.w(Tag, "Failed to read incoming message", e)
@ -102,7 +109,7 @@ class TransferThread(context: Context, device: Device, socket: BluetoothSocket,
} catch {
case e: IOException => Log.e(Tag, "Failed to close socket", e);
} finally {
handler.onConnectionClosed(new Device(device.btDevice.get, false), null)
handler.onConnectionClosed(connectionOpened, device.id)
}
}

View file

@ -4,6 +4,7 @@ import android.app.ListFragment
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.os.Bundle
import android.support.v4.content.LocalBroadcastManager
import android.support.v7.widget.Toolbar
import android.view.View.OnClickListener
import android.view.inputmethod.EditorInfo
import android.view.{KeyEvent, LayoutInflater, View, ViewGroup}
@ -11,9 +12,11 @@ import android.widget.TextView.OnEditorActionListener
import android.widget._
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.EnsichatActivity
import com.nutomic.ensichat.protocol.body.Text
import com.nutomic.ensichat.protocol.{Address, ChatService, Message}
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.core.messages.body.Text
import com.nutomic.ensichat.core.messages.Message
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.core.ConnectionHandler
import com.nutomic.ensichat.service.CallbackHandler
import com.nutomic.ensichat.views.{DatesAdapter, MessagesAdapter}
/**
@ -29,11 +32,11 @@ class ChatFragment extends ListFragment with OnClickListener {
this.address = address
}
private lazy val database = new Database(getActivity)
private lazy val activity = getActivity.asInstanceOf[EnsichatActivity]
private var address: Address = _
private var chatService: ChatService = _
private var chatService: ConnectionHandler = _
private var sendButton: Button = _
@ -46,14 +49,13 @@ class ChatFragment extends ListFragment with OnClickListener {
override def onActivityCreated(savedInstanceState: Bundle): Unit = {
super.onActivityCreated(savedInstanceState)
val activity = getActivity.asInstanceOf[EnsichatActivity]
activity.runOnServiceConnected(() => {
chatService = activity.service
chatService = activity.service.get
database.getContact(address).foreach(c => getActivity.setTitle(c.name))
activity.database.get.getContact(address).foreach(c => getActivity.setTitle(c.name))
adapter = new DatesAdapter(getActivity,
new MessagesAdapter(getActivity, database.getMessagesCursor(address, None), address))
new MessagesAdapter(getActivity, activity.database.get.getMessages(address), address))
if (listView != null) {
listView.setAdapter(adapter)
@ -62,8 +64,11 @@ class ChatFragment extends ListFragment with OnClickListener {
}
override def onCreateView(inflater: LayoutInflater, container: ViewGroup,
savedInstanceState: Bundle): View = {
val view: View = inflater.inflate(R.layout.fragment_chat, container, false)
savedInstanceState: Bundle): View = {
val view = inflater.inflate(R.layout.fragment_chat, container, false)
val toolbar = view.findViewById(R.id.toolbar).asInstanceOf[Toolbar]
activity.setSupportActionBar(toolbar)
activity.getSupportActionBar.setDisplayHomeAsUpEnabled(true)
sendButton = view.findViewById(R.id.send).asInstanceOf[Button]
sendButton.setOnClickListener(this)
messageText = view.findViewById(R.id.message).asInstanceOf[EditText]
@ -88,7 +93,7 @@ class ChatFragment extends ListFragment with OnClickListener {
address = new Address(savedInstanceState.getByteArray("address"))
LocalBroadcastManager.getInstance(getActivity)
.registerReceiver(onMessageReceivedReceiver, new IntentFilter(ChatService.ActionMessageReceived))
.registerReceiver(onMessageReceivedReceiver, new IntentFilter(CallbackHandler.ActionMessageReceived))
}
override def onSaveInstanceState(outState: Bundle): Unit = {
@ -119,13 +124,14 @@ class ChatFragment extends ListFragment with OnClickListener {
*/
private val onMessageReceivedReceiver = new BroadcastReceiver {
override def onReceive(context: Context, intent: Intent): Unit = {
val msg = intent.getSerializableExtra(ChatService.ExtraMessage).asInstanceOf[Message]
val msg = intent.getSerializableExtra(CallbackHandler.ExtraMessage).asInstanceOf[Message]
if (!Set(msg.header.origin, msg.header.target).contains(address))
return
msg.body match {
case _: Text =>
adapter.changeCursor(database.getMessagesCursor(address, None))
val messages = activity.database.get.getMessages(address)
adapter.replaceItems(messages)
case _ =>
}
}

View file

@ -0,0 +1,160 @@
package com.nutomic.ensichat.fragments
import java.io.File
import android.app.ListFragment
import android.bluetooth.BluetoothAdapter
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.net.Uri
import android.os.Bundle
import android.preference.PreferenceManager
import android.support.design.widget.FloatingActionButton
import android.support.v4.content.{ContextCompat, LocalBroadcastManager}
import android.support.v7.widget.Toolbar
import android.view.View.OnClickListener
import android.view._
import android.widget.{ListView, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.{ConnectionsActivity, EnsichatActivity, MainActivity, SettingsActivity}
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import com.nutomic.ensichat.service.{CallbackHandler, ChatService}
import com.nutomic.ensichat.views.UsersAdapter
import scala.collection.JavaConversions._
/**
* Lists all nearby, connected devices.
*/
class ContactsFragment extends ListFragment with OnClickListener {
private lazy val adapter = new UsersAdapter(getActivity)
private lazy val database = activity.database.get
private lazy val lbm = LocalBroadcastManager.getInstance(getActivity)
private lazy val activity = getActivity.asInstanceOf[EnsichatActivity]
private var title: TextView = _
private var subtitle: TextView = _
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
setListAdapter(adapter)
setHasOptionsMenu(true)
lbm.registerReceiver(onContactsUpdatedListener, new IntentFilter(CallbackHandler.ActionContactsUpdated))
lbm.registerReceiver(onConnectionsChangedListener, new IntentFilter(CallbackHandler.ActionConnectionsChanged))
}
override def onResume(): Unit = {
super.onResume()
activity.runOnServiceConnected(() => {
adapter.clear()
adapter.addAll(database.getContacts)
updateConnections()
})
}
override def onDestroy(): Unit = {
super.onDestroy()
lbm.unregisterReceiver(onContactsUpdatedListener)
lbm.unregisterReceiver(onConnectionsChangedListener)
}
override def onCreateView(inflater: LayoutInflater, container: ViewGroup,
savedInstanceState: Bundle): View = {
val v = inflater.inflate(R.layout.fragment_contacts, container, false)
val toolbar = v.findViewById(R.id.toolbar).asInstanceOf[Toolbar]
v.findViewById(R.id.title_holder).setOnClickListener(this)
activity.setSupportActionBar(toolbar)
toolbar.setNavigationIcon(R.drawable.ic_launcher)
title = v.findViewById(R.id.title).asInstanceOf[TextView]
subtitle = v.findViewById(R.id.subtitle).asInstanceOf[TextView]
val fab = v.findViewById(R.id.fab).asInstanceOf[FloatingActionButton]
fab.setOnClickListener(this)
updateConnections()
v
}
override def onCreateOptionsMenu(menu: Menu, inflater: MenuInflater): Unit = {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.main, menu)
}
override def onClick(v: View): Unit =
startActivity(new Intent(getActivity, classOf[ConnectionsActivity]))
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case R.id.share_app =>
val pm = getActivity.getPackageManager
val ai = pm.getInstalledApplications(0).find(_.sourceDir.contains(getActivity.getPackageName))
val intent = new Intent()
intent.setAction(Intent.ACTION_SEND)
intent.setType("*/*")
intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(new File(ai.get.sourceDir)))
startActivity(intent)
true
case R.id.my_address =>
val prefs = PreferenceManager.getDefaultSharedPreferences(getActivity)
val fragment = new UserInfoFragment()
val bundle = new Bundle()
bundle.putString(
UserInfoFragment.ExtraAddress, ChatService.newCrypto(getActivity).localAddress.toString)
bundle.putString(
UserInfoFragment.ExtraUserName, prefs.getString(SettingsInterface.KeyUserName, ""))
fragment.setArguments(bundle)
fragment.show(getFragmentManager, "dialog")
true
case R.id.settings =>
startActivity(new Intent(getActivity, classOf[SettingsActivity]))
true
case _ =>
super.onOptionsItemSelected(item)
}
/**
* Opens a chat with the clicked device.
*/
override def onListItemClick(l: ListView, v: View, position: Int, id: Long): Unit =
getActivity.asInstanceOf[MainActivity].openChat(adapter.getItem(position).address)
private val onContactsUpdatedListener = new BroadcastReceiver() {
override def onReceive(context: Context, intent: Intent): Unit = {
getActivity.runOnUiThread(new Runnable {
override def run(): Unit = {
adapter.clear()
database.getContacts.foreach(adapter.add)
}
})
}
}
private val onConnectionsChangedListener = new BroadcastReceiver {
override def onReceive(context: Context, intent: Intent): Unit = updateConnections()
}
/**
* Updates TextViews in actionbar with current connections.
*/
private def updateConnections(): Unit = {
if (activity.service.isEmpty || title == null)
return
val service = activity.service.get
val connections = service.connections()
val count = connections.size
val color = count match {
case 0 => R.color.title_connections_error
case 1 => R.color.title_connections_warning
case _ => R.color.title_connections_ok
}
title.setText(getResources.getQuantityString(R.plurals.title_connections, count, count.toString))
title.setTextColor(ContextCompat.getColor(getActivity, color))
subtitle.setText(connections.map(service.getUser(_).name).mkString(", "))
subtitle.setVisibility(if (count == 0) View.GONE else View.VISIBLE)
}
}

View file

@ -0,0 +1,60 @@
package com.nutomic.ensichat.fragments
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.content.{Intent, SharedPreferences}
import android.os.Bundle
import android.preference.{PreferenceFragment, PreferenceManager}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.EnsichatActivity
import com.nutomic.ensichat.core.interfaces.SettingsInterface._
import com.nutomic.ensichat.core.messages.body.UserInfo
import com.nutomic.ensichat.fragments.SettingsFragment._
import com.nutomic.ensichat.service.ChatService
object SettingsFragment {
val Version = "version"
}
/**
* Settings screen.
*/
class SettingsFragment extends PreferenceFragment with OnSharedPreferenceChangeListener {
private lazy val activity = getActivity.asInstanceOf[EnsichatActivity]
private lazy val version = findPreference(Version)
private lazy val prefs = PreferenceManager.getDefaultSharedPreferences(getActivity)
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.settings)
val packageInfo = getActivity.getPackageManager.getPackageInfo(getActivity.getPackageName, 0)
version.setSummary(packageInfo.versionName)
prefs.registerOnSharedPreferenceChangeListener(this)
}
override def onDestroy(): Unit = {
super.onDestroy()
prefs.unregisterOnSharedPreferenceChangeListener(this)
}
/**
* Sends the updated username or status to all contacts.
*/
override def onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
key match {
case KeyUserName | KeyUserStatus =>
val ui = new UserInfo(prefs.getString(KeyUserName, ""), prefs.getString(KeyUserStatus, ""))
activity.database.get.getContacts.foreach(c => activity.service.get.sendTo(c.address, ui))
case KeyAddresses =>
val intent = new Intent(getActivity, classOf[ChatService])
intent.setAction(ChatService.ActionNetworkChanged)
getActivity.startService(intent)
case _ =>
}
}
}

View file

@ -0,0 +1,86 @@
package com.nutomic.ensichat.fragments
import android.app.{AlertDialog, Dialog, DialogFragment}
import android.content.{ClipData, ClipboardManager, Context}
import android.graphics.{Bitmap, Color}
import android.os.Bundle
import android.view.View.{OnClickListener, OnLongClickListener}
import android.view.{LayoutInflater, View}
import android.widget.{ImageView, TextView, Toast}
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.util.IdenticonGenerator
object UserInfoFragment {
val ExtraAddress = "address"
val ExtraUserName = "user_name"
}
/**
* Displays identicon, username and address for a user.
*
* Use [[UserInfoFragment#getInstance]] to invoke.
*/
class UserInfoFragment extends DialogFragment with OnLongClickListener {
private lazy val address = new Address(getArguments.getString(UserInfoFragment.ExtraAddress))
private lazy val userName = getArguments.getString(UserInfoFragment.ExtraUserName)
override def onCreateDialog(savedInstanceState: Bundle): Dialog = {
val view = LayoutInflater.from(getActivity).inflate(R.layout.fragment_identicon, null)
view.findViewById(R.id.identicon)
.asInstanceOf[ImageView]
.setImageBitmap(IdenticonGenerator.generate(address, (150, 150), getActivity))
val addressTextView = view.findViewById(R.id.address)
.asInstanceOf[TextView]
addressTextView.setText(getString(R.string.address_colon, address.toString()))
addressTextView.setOnLongClickListener(this)
addressTextView.setOnClickListener(new OnClickListener {
override def onClick(v: View): Unit = onLongClick(v)
})
val matrix = new QRCodeWriter().encode(address.toString(), BarcodeFormat.QR_CODE, 150, 150)
view.findViewById(R.id.qr_code)
.asInstanceOf[ImageView]
.setImageBitmap(renderMatrix(matrix))
new AlertDialog.Builder(getActivity)
.setTitle(userName)
.setView(view)
.setPositiveButton(android.R.string.ok, null)
.create()
}
override def onLongClick(v: View): Boolean = {
val cm = getContext.getSystemService(Context.CLIPBOARD_SERVICE).asInstanceOf[ClipboardManager]
val clip = ClipData.newPlainText(getContext.getString(R.string.ensichat_user_address), address.toString)
cm.setPrimaryClip(clip)
Toast.makeText(getContext, R.string.address_copied_to_clipboard, Toast.LENGTH_SHORT).show()
true
}
/**
* Converts a [[BitMatrix]] instance into a [[Bitmap]].
*/
private def renderMatrix(bitMatrix: BitMatrix): Bitmap = {
val height = bitMatrix.getHeight
val width = bitMatrix.getWidth
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x <- 0 until width) {
for (y <- 0 until height) {
val color =
if (bitMatrix.get(x,y))
Color.BLACK
else
Color.WHITE
bmp.setPixel(x, y, color)
}
}
bmp
}
}

View file

@ -0,0 +1,16 @@
package com.nutomic.ensichat.service
import android.content.{BroadcastReceiver, Context, Intent}
import android.preference.PreferenceManager
/**
* Starts [[ChatService]] on boot if preference is enabled.
*/
class BootReceiver extends BroadcastReceiver {
override def onReceive(context: Context, intent: Intent): Unit = {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
context.startService(new Intent(context, classOf[ChatService]))
}
}

View file

@ -0,0 +1,49 @@
package com.nutomic.ensichat.service
import android.content.Intent
import android.support.v4.content.LocalBroadcastManager
import com.nutomic.ensichat.core.interfaces.CallbackInterface
import com.nutomic.ensichat.core.ConnectionHandler
import com.nutomic.ensichat.core.messages.Message
import com.nutomic.ensichat.service.CallbackHandler._
object CallbackHandler {
val ActionMessageReceived = "message_received"
val ActionConnectionsChanged = "connections_changed"
val ActionContactsUpdated = "contacts_updated"
val ExtraMessage = "extra_message"
}
/**
* Receives events from [[ConnectionHandler]] and sends them as local broadcasts.
*/
class CallbackHandler(chatService: ChatService, notificationHandler: NotificationHandler)
extends CallbackInterface {
def onMessageReceived(msg: Message): Unit = {
notificationHandler.onMessageReceived(msg)
val i = new Intent(ActionMessageReceived)
i.putExtra(ExtraMessage, msg)
LocalBroadcastManager.getInstance(chatService)
.sendBroadcast(i)
}
def onConnectionsChanged(): Unit = {
val i = new Intent(ActionConnectionsChanged)
LocalBroadcastManager.getInstance(chatService)
.sendBroadcast(i)
notificationHandler
.updatePersistentNotification(chatService.getConnectionHandler.connections().size)
}
def onContactsUpdated(): Unit = {
val i = new Intent(ActionContactsUpdated)
LocalBroadcastManager.getInstance(chatService)
.sendBroadcast(i)
}
}

View file

@ -0,0 +1,77 @@
package com.nutomic.ensichat.service
import java.io.File
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.content.{Context, Intent, IntentFilter}
import android.net.ConnectivityManager
import android.os.Handler
import com.nutomic.ensichat.bluetooth.BluetoothInterface
import com.nutomic.ensichat.core.interfaces.TransmissionInterface
import com.nutomic.ensichat.core.util.{Crypto, Database}
import com.nutomic.ensichat.core.ConnectionHandler
import com.nutomic.ensichat.util.{NetworkChangedReceiver, SettingsWrapper}
object ChatService {
case class Binder(service: ChatService) extends android.os.Binder
private def keyFolder(context: Context) = new File(context.getFilesDir, "keys")
def newCrypto(context: Context) = new Crypto(new SettingsWrapper(context), keyFolder(context))
val ActionNetworkChanged = "network_changed"
}
class ChatService extends Service {
private lazy val binder = new ChatService.Binder(this)
private lazy val notificationHandler = new NotificationHandler(this)
private val callbackHandler = new CallbackHandler(this, notificationHandler)
private def settingsWrapper = new SettingsWrapper(this)
lazy val database = new Database(getDatabasePath("database"), settingsWrapper, callbackHandler)
private lazy val connectionHandler =
new ConnectionHandler(settingsWrapper, database, callbackHandler, ChatService.newCrypto(this))
private val networkReceiver = new NetworkChangedReceiver()
override def onBind(intent: Intent) = binder
override def onStartCommand(intent: Intent, flags: Int, startId: Int): Int = {
Option(intent).foreach { i =>
if (i.getAction == ChatService.ActionNetworkChanged)
connectionHandler.internetConnectionChanged()
}
Service.START_STICKY
}
/**
* Generates keys and starts Bluetooth interface.
*/
override def onCreate(): Unit = {
super.onCreate()
notificationHandler.updatePersistentNotification(getConnectionHandler.connections().size)
var additionalInterfaces = Set[TransmissionInterface]()
if (Option(BluetoothAdapter.getDefaultAdapter).isDefined)
additionalInterfaces += new BluetoothInterface(this, new Handler(), connectionHandler)
connectionHandler.start(additionalInterfaces)
registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
}
override def onDestroy(): Unit = {
notificationHandler.stopPersistentNotification()
connectionHandler.stop()
unregisterReceiver(networkReceiver)
}
def getConnectionHandler = connectionHandler
}

View file

@ -0,0 +1,91 @@
package com.nutomic.ensichat.service
import android.app.{Notification, NotificationManager, PendingIntent}
import android.content.{Context, Intent}
import android.preference.PreferenceManager
import android.support.v4.app.NotificationCompat
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.MainActivity
import com.nutomic.ensichat.core.messages.body.Text
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import com.nutomic.ensichat.core.messages.Message
import com.nutomic.ensichat.service.NotificationHandler._
object NotificationHandler {
private val NotificationIdRunning = 1
private val NotificationIdNewMessage = 2
}
/**
* Displays notifications for new messages and while the app is running.
*/
class NotificationHandler(context: Context) {
private lazy val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE)
.asInstanceOf[NotificationManager]
private var persistentNotificationShutdown = false
def updatePersistentNotification(connections: Int): Unit = {
if (persistentNotificationShutdown)
return
val intent = PendingIntent.getActivity(context, 0, new Intent(context, classOf[MainActivity]), 0)
val info = context.getResources
.getQuantityString(R.plurals.notification_connections, connections, connections.toString)
val notification = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(context.getString(R.string.app_name))
.setContentText(info)
.setContentIntent(intent)
.setOngoing(true)
.setPriority(Notification.PRIORITY_MIN)
.build()
notificationManager.notify(NotificationIdRunning, notification)
}
/**
* Cancels the persistent notification.
*
* After calling this method, [[updatePersistentNotification()]] will have no effect.
*/
def stopPersistentNotification() = {
persistentNotificationShutdown = true
notificationManager.cancel(NotificationIdRunning)
}
def onMessageReceived(msg: Message): Unit = msg.body match {
case text: Text =>
if (msg.header.origin == ChatService.newCrypto(context).localAddress)
return
val pi = PendingIntent.getActivity(context, 0, new Intent(context, classOf[MainActivity]), 0)
val notification = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(context.getString(R.string.notification_message))
.setContentText(text.text)
.setDefaults(defaults())
.setContentIntent(pi)
.setAutoCancel(true)
.build()
notificationManager.notify(NotificationIdNewMessage, notification)
case _ =>
}
/**
* Returns the default notification options that should be used.
*/
private def defaults(): Int = {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
if (sp.getBoolean(SettingsInterface.KeyNotificationSoundsOn, SettingsInterface.DefaultNotificationSoundsOn))
Notification.DEFAULT_ALL
else
Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS
}
}

View file

@ -3,7 +3,7 @@ package com.nutomic.ensichat.util
import android.content.Context
import android.graphics.Bitmap.Config
import android.graphics.{Bitmap, Canvas, Color}
import com.nutomic.ensichat.protocol.Address
import com.nutomic.ensichat.core.routing.Address
/**
* Calculates a unique identicon for the given hash.

View file

@ -0,0 +1,31 @@
package com.nutomic.ensichat.util
import android.content.{BroadcastReceiver, Context, Intent}
import android.net.ConnectivityManager
import com.nutomic.ensichat.service.ChatService
/**
* Forwards network changed intents to [[ChatService]].
*
* HACK: Because [[ConnectivityManager.CONNECTIVITY_ACTION]] is a sticky intent, and we register it
* from Scala, an intent is sent as soon as the receiver is registered. As a workaround, we
* ignore the first intent received.
* Alternatively, we can register the receiver in the manifest, but that will start the
* service (so it only works if the service runs permanently, with no exit).
*/
class NetworkChangedReceiver extends BroadcastReceiver {
private var isFirstIntent = true
override def onReceive(context: Context, intent: Intent): Unit = {
if (isFirstIntent) {
isFirstIntent = false
return
}
val intent = new Intent(context, classOf[ChatService])
intent.setAction(ChatService.ActionNetworkChanged)
context.startService(intent)
}
}

View file

@ -0,0 +1,23 @@
package com.nutomic.ensichat.util
import android.content.Context
import android.preference.PreferenceManager
import com.nutomic.ensichat.core.interfaces.SettingsInterface
class SettingsWrapper(context: Context) extends SettingsInterface {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override def get[T](key: String, default: T): T = default match {
case s: String => prefs.getString(key, s).asInstanceOf[T]
case i: Int => prefs.getInt(key, i).asInstanceOf[T]
case l: Long => prefs.getLong(key, l).asInstanceOf[T]
}
override def put[T](key: String, value: T): Unit = value match {
case s: String => prefs.edit().putString(key, s).apply()
case i: Int => prefs.edit().putInt(key, i).apply()
case l: Long => prefs.edit().putLong(key, l).apply()
}
}

View file

@ -0,0 +1,37 @@
package com.nutomic.ensichat.views
import java.text.DateFormat
import android.content.Context
import com.mobsandgeeks.adapters.{Sectionizer, SimpleSectionAdapter}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.messages.Message
import scala.collection.JavaConverters._
object DatesAdapter {
private val Sectionizer = new Sectionizer[Message]() {
override def getSectionTitleForItem(item: Message): String = {
DateFormat
.getDateInstance(DateFormat.MEDIUM)
.format(item.header.time.get.toDate)
}
}
}
/**
* Wraps [[MessagesAdapter]] and shows date between messages.
*/
class DatesAdapter(context: Context, messagesAdapter: MessagesAdapter)
extends SimpleSectionAdapter[Message](context, messagesAdapter, R.layout.item_date, R.id.date,
DatesAdapter.Sectionizer) {
def replaceItems(items: Seq[Message]): Unit = {
messagesAdapter.clear()
messagesAdapter.addAll(items.asJava)
notifyDataSetChanged()
}
}

View file

@ -1,16 +1,27 @@
package com.nutomic.ensichat.views
import java.text.DateFormat
import java.util
import android.content.Context
import android.database.Cursor
import android.view._
import android.widget._
import com.mobsandgeeks.adapters.{InstantCursorAdapter, SimpleSectionAdapter, ViewHandler}
import com.mobsandgeeks.adapters.{InstantAdapter, SimpleSectionAdapter, ViewHandler}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.protocol.body.Text
import com.nutomic.ensichat.protocol.{Address, Message}
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.core.messages.body.Text
import com.nutomic.ensichat.core.messages.Message
import com.nutomic.ensichat.core.routing.Address
import com.nutomic.ensichat.views.MessagesAdapter._
object MessagesAdapter {
private def itemsAsMutableList(items: Seq[Message]): util.List[Message] = {
val list = new util.ArrayList[Message]()
items.foreach(list.add)
list
}
}
/**
* Displays [[Message]]s in ListView.
@ -18,8 +29,9 @@ import com.nutomic.ensichat.util.Database
* We just use the instant adapter for compatibility with [[SimpleSectionAdapter]], but don't use
* the annotations (as it breaks separation of presentation and content).
*/
class MessagesAdapter(context: Context, cursor: Cursor, remoteAddress: Address) extends
InstantCursorAdapter[Message](context, R.layout.item_message, classOf[Message], cursor) {
class MessagesAdapter(context: Context, items: Seq[Message], remoteAddress: Address) extends
InstantAdapter[Message](context, R.layout.item_message, classOf[Message],
itemsAsMutableList(items)) {
private val MessagePaddingLarge = 50
private val MessagePaddingSmall = 10
@ -35,7 +47,7 @@ class MessagesAdapter(context: Context, cursor: Cursor, remoteAddress: Address)
text.setText(msg.body.asInstanceOf[Text].text)
val formattedDate = DateFormat
.getTimeInstance(DateFormat.SHORT)
.format(msg.header.time.get)
.format(msg.header.time.get.toDate)
time.setText(formattedDate)
val paddingLarge = (MessagePaddingLarge * context.getResources.getDisplayMetrics.density).toInt
@ -52,6 +64,4 @@ class MessagesAdapter(context: Context, cursor: Cursor, remoteAddress: Address)
}
})
override def getInstance (cursor: Cursor) = Database.messageFromCursor(cursor)
}

View file

@ -7,8 +7,8 @@ import android.view.View.OnClickListener
import android.view.{LayoutInflater, View, ViewGroup}
import android.widget.{ArrayAdapter, ImageView, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.fragments.IdenticonFragment
import com.nutomic.ensichat.protocol.{Crypto, User}
import com.nutomic.ensichat.core.util.User
import com.nutomic.ensichat.fragments.UserInfoFragment
import com.nutomic.ensichat.util.IdenticonGenerator
/**
@ -38,12 +38,12 @@ class UsersAdapter(activity: Activity) extends ArrayAdapter[User](activity, 0) w
view
}
override def onClick (v: View): Unit = {
override def onClick(v: View): Unit = {
val user = v.getTag.asInstanceOf[User]
val fragment = new IdenticonFragment()
val fragment = new UserInfoFragment()
val bundle = new Bundle()
bundle.putString(IdenticonFragment.ExtraAddress, user.address.toString)
bundle.putString(IdenticonFragment.ExtraUserName, user.name)
bundle.putString(UserInfoFragment.ExtraAddress, user.address.toString)
bundle.putString(UserInfoFragment.ExtraUserName, user.name)
fragment.setArguments(bundle)
fragment.show(activity.getFragmentManager, "dialog")
}

View file

@ -1,39 +0,0 @@
package com.nutomic.ensichat.activities
import android.bluetooth.BluetoothAdapter
import android.content._
import android.test.ActivityUnitTestCase
import junit.framework.Assert
class MainActivityTest extends ActivityUnitTestCase[MainActivity](classOf[MainActivity]) {
var lastIntent: Intent = _
class ActivityContextWrapper(context: Context) extends ContextWrapper(context) {
override def startService(service: Intent): ComponentName = {
lastIntent = service
null
}
override def stopService(name: Intent): Boolean = {
lastIntent = name
true
}
override def bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean = false
override def unbindService(conn: ServiceConnection): Unit = {}
}
override def setUp(): Unit = {
setActivityContext(new ActivityContextWrapper(getInstrumentation.getTargetContext))
startActivity(new Intent(), null, null)
}
def testRequestBluetoothDiscoverable(): Unit = {
val intent: Intent = getStartedActivityIntent
Assert.assertEquals(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE, intent.getAction)
Assert.assertEquals(0, intent.getIntExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, -1))
}
}

View file

@ -1,35 +0,0 @@
package com.nutomic.ensichat.protocol
import android.test.AndroidTestCase
import junit.framework.Assert._
class CryptoTest extends AndroidTestCase {
private lazy val crypto: Crypto = new Crypto(getContext)
override def setUp(): Unit = {
super.setUp()
if (!crypto.localKeysExist) {
crypto.generateLocalKeys()
}
}
def testSignVerify(): Unit = {
MessageTest.messages.foreach { m =>
val signed = crypto.sign(m)
assertTrue(crypto.verify(signed, crypto.getLocalPublicKey))
assertEquals(m.header, signed.header)
assertEquals(m.body, signed.body)
}
}
def testEncryptDecrypt(): Unit = {
MessageTest.messages.foreach{ m =>
val encrypted = crypto.encrypt(crypto.sign(m), crypto.getLocalPublicKey)
val decrypted = crypto.decrypt(encrypted)
assertEquals(m.body, decrypted.body)
assertEquals(m.header, encrypted.header)
}
}
}

View file

@ -1,76 +0,0 @@
package com.nutomic.ensichat.protocol
import java.io.ByteArrayInputStream
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.MessageTest._
import com.nutomic.ensichat.protocol.body.{ConnectionInfo, ConnectionInfoTest, Text}
import com.nutomic.ensichat.protocol.header.ContentHeaderTest._
import com.nutomic.ensichat.protocol.header.MessageHeader
import junit.framework.Assert._
import scala.collection.immutable.TreeSet
object MessageTest {
val m1 = new Message(h1, new Text("first"))
val m2 = new Message(h2, new Text("second"))
val m3 = new Message(h3, new Text("third"))
val messages = Set(m1, m2, m3)
}
class MessageTest extends AndroidTestCase {
private lazy val crypto: Crypto = new Crypto(getContext)
override def setUp(): Unit = {
super.setUp()
if (!crypto.localKeysExist) {
crypto.generateLocalKeys()
}
}
def testOrder(): Unit = {
var messages = new TreeSet[Message]()(Message.Ordering)
messages += m1
messages += m2
assertEquals(m1, messages.firstKey)
messages = new TreeSet[Message]()(Message.Ordering)
messages += m2
messages += m3
assertEquals(m2, messages.firstKey)
}
def testSerializeSigned(): Unit = {
val header = new MessageHeader(ConnectionInfo.Type, AddressTest.a4, AddressTest.a2, 0)
val m = new Message(header, ConnectionInfoTest.generateCi(getContext))
val signed = crypto.sign(m)
val bytes = signed.write
val read = Message.read(new ByteArrayInputStream(bytes))
assertEquals(signed, read)
assertTrue(crypto.verify(read, crypto.getLocalPublicKey))
}
def testSerializeEncrypted(): Unit = {
MessageTest.messages.foreach{ m =>
val signed = crypto.sign(m)
val encrypted = crypto.encrypt(signed, crypto.getLocalPublicKey)
val bytes = encrypted.write
val read = Message.read(new ByteArrayInputStream(bytes))
assertEquals(encrypted.crypto, read.crypto)
val decrypted = crypto.decrypt(read)
assertEquals(m.header, decrypted.header)
assertEquals(m.body, decrypted.body)
assertTrue(crypto.verify(decrypted, crypto.getLocalPublicKey))
}
}
}

View file

@ -1,105 +0,0 @@
package com.nutomic.ensichat.protocol
import java.util.{Date, GregorianCalendar}
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.body.{Text, UserInfo}
import com.nutomic.ensichat.protocol.header.ContentHeader
import junit.framework.Assert._
class RouterTest extends AndroidTestCase {
private def neighbors() = Set[Address](AddressTest.a1, AddressTest.a2, AddressTest.a3)
private val msg = generateMessage(AddressTest.a1, AddressTest.a4, 1)
/**
* Messages should be sent to all neighbors.
*/
def testFlooding(): Unit = {
var sentTo = Set[Address]()
val router: Router = new Router(neighbors,
(a, m) => {
sentTo += a
})
router.onReceive(msg)
assertEquals(neighbors(), sentTo)
}
def testMessageSame(): Unit = {
val router: Router = new Router(neighbors,
(a, m) => {
assertEquals(msg.header.origin, m.header.origin)
assertEquals(msg.header.target, m.header.target)
assertEquals(msg.header.seqNum, m.header.seqNum)
assertEquals(msg.header.protocolType, m.header.protocolType)
assertEquals(msg.header.hopCount + 1, m.header.hopCount)
assertEquals(msg.header.hopLimit, m.header.hopLimit)
assertEquals(msg.body, m.body)
assertEquals(msg.crypto, m.crypto)
})
router.onReceive(msg)
}
/**
* Messages from different senders with the same sequence number should be forwarded.
*/
def testDifferentSenders(): Unit = {
var sentTo = Set[Address]()
val router: Router = new Router(neighbors, (a, m) => sentTo += a)
router.onReceive(msg)
assertEquals(neighbors(), sentTo)
sentTo = Set[Address]()
router.onReceive(generateMessage(AddressTest.a2, AddressTest.a4, 1))
assertEquals(neighbors(), sentTo)
}
/**
* Messages from the same sender with the same sequence number should be ignored.
*/
def testIgnores(): Unit = {
var sentTo = Set[Address]()
val router: Router = new Router(neighbors, (a, m) => sentTo += a)
router.onReceive(msg)
assertEquals(neighbors(), sentTo)
sentTo = Set[Address]()
router.onReceive(generateMessage(AddressTest.a1, AddressTest.a2, 1))
assertTrue(sentTo.isEmpty)
}
def testDiscardOldIgnores(): Unit = {
def test(first: Int, second: Int) {
var sentTo = Set[Address]()
val router: Router = new Router(neighbors, (a, m) => sentTo += a)
router.onReceive(generateMessage(AddressTest.a1, AddressTest.a3, first))
router.onReceive(generateMessage(AddressTest.a1, AddressTest.a3, second))
sentTo = Set[Address]()
router.onReceive(generateMessage(AddressTest.a1, AddressTest.a3, first))
assertEquals(neighbors(), sentTo)
}
test(1, ContentHeader.SeqNumRange.last)
test(ContentHeader.SeqNumRange.last / 2, ContentHeader.SeqNumRange.last)
test(ContentHeader.SeqNumRange.last / 2, 1)
}
def testHopLimit(): Unit = Range(19, 22).foreach { i =>
val msg = new Message(
new ContentHeader(AddressTest.a1, AddressTest.a2, 1, 1, Some(1), Some(new Date()), i), new Text(""))
val router: Router = new Router(neighbors, (a, m) => fail())
router.onReceive(msg)
}
private def generateMessage(sender: Address, receiver: Address, seqNum: Int): Message = {
val header = new ContentHeader(sender, receiver, seqNum, UserInfo.Type, Some(5),
Some(new GregorianCalendar(2014, 6, 10).getTime))
new Message(header, new UserInfo("", ""))
}
}

View file

@ -1,11 +0,0 @@
package com.nutomic.ensichat.protocol
object UserTest {
val u1 = new User(AddressTest.a1, "one", "s1")
val u2 = new User(AddressTest.a2, "two", "s2")
val u3 = new User(AddressTest.a3, "three", "s3")
}

View file

@ -1,28 +0,0 @@
package com.nutomic.ensichat.protocol.body
import android.content.Context
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.Crypto
import junit.framework.Assert
object ConnectionInfoTest {
def generateCi(context: Context) = {
val crypto = new Crypto(context)
if (!crypto.localKeysExist)
crypto.generateLocalKeys()
new ConnectionInfo(crypto.getLocalPublicKey)
}
}
class ConnectionInfoTest extends AndroidTestCase {
def testWriteRead(): Unit = {
val ci = ConnectionInfoTest.generateCi(getContext)
val bytes = ci.write
val body = ConnectionInfo.read(bytes)
Assert.assertEquals(ci.key, body.asInstanceOf[ConnectionInfo].key)
}
}

View file

@ -1,15 +0,0 @@
package com.nutomic.ensichat.protocol.body
import android.test.AndroidTestCase
import junit.framework.Assert
class UserInfoTest extends AndroidTestCase {
def testWriteRead(): Unit = {
val name = new UserInfo("name", "status")
val bytes = name.write
val body = UserInfo.read(bytes)
Assert.assertEquals(name, body.asInstanceOf[UserInfo])
}
}

View file

@ -1,45 +0,0 @@
package com.nutomic.ensichat.protocol.header
import java.util.{GregorianCalendar, Date}
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.body.Text
import com.nutomic.ensichat.protocol.{Address, AddressTest}
import junit.framework.Assert._
object ContentHeaderTest {
val h1 = new ContentHeader(AddressTest.a1, AddressTest.a2, 1234,
Text.Type, Some(123), Some(new GregorianCalendar(1970, 1, 1).getTime), 5)
val h2 = new ContentHeader(AddressTest.a1, AddressTest.a3,
30000, Text.Type, Some(8765), Some(new GregorianCalendar(2014, 6, 10).getTime), 20)
val h3 = new ContentHeader(AddressTest.a4, AddressTest.a2,
250, Text.Type, Some(77), Some(new GregorianCalendar(2020, 11, 11).getTime), 123)
val h4 = new ContentHeader(Address.Null, Address.Broadcast,
ContentHeader.SeqNumRange.last, 0, Some(0xffff), Some(new Date(0L)), 0xff)
val h5 = new ContentHeader(Address.Broadcast, Address.Null,
0, 0xff, Some(0), Some(new Date(0xffffffffL)), 0)
val headers = Set(h1, h2, h3, h4, h5)
}
class ContentHeaderTest extends AndroidTestCase {
def testSerialize(): Unit = {
ContentHeaderTest.headers.foreach{h =>
val bytes = h.write(0)
assertEquals(bytes.length, h.length)
val (mh, length) = MessageHeader.read(bytes)
val chBytes = bytes.drop(mh.length)
val (header, remaining) = ContentHeader.read(mh, chBytes)
assertEquals(h, header)
assertEquals(0, remaining.length)
}
}
}

View file

@ -1,33 +0,0 @@
package com.nutomic.ensichat.protocol.header
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.header.MessageHeaderTest._
import com.nutomic.ensichat.protocol.{Address, AddressTest}
import junit.framework.Assert._
object MessageHeaderTest {
val h1 = new MessageHeader(ContentHeader.ContentMessageType, AddressTest.a1, AddressTest.a2, 1234,
0)
val h2 = new MessageHeader(ContentHeader.ContentMessageType, Address.Null, Address.Broadcast,
ContentHeader.SeqNumRange.last, 0xff)
val h3 = new MessageHeader(ContentHeader.ContentMessageType, Address.Broadcast, Address.Null, 0)
val headers = Set(h1, h2, h3)
}
class MessageHeaderTest extends AndroidTestCase {
def testSerialize(): Unit = {
headers.foreach{h =>
val bytes = h.write(0)
val (header, length) = MessageHeader.read(bytes)
assertEquals(h, header)
assertEquals(MessageHeader.Length, length)
}
}
}

View file

@ -1,112 +0,0 @@
package com.nutomic.ensichat.util
import java.util.concurrent.CountDownLatch
import android.content._
import android.database.DatabaseErrorHandler
import android.database.sqlite.SQLiteDatabase
import android.support.v4.content.LocalBroadcastManager
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.MessageTest._
import com.nutomic.ensichat.protocol.body.CryptoData
import com.nutomic.ensichat.protocol.header.{ContentHeader, ContentHeaderTest}
import com.nutomic.ensichat.protocol.header.ContentHeaderTest._
import com.nutomic.ensichat.protocol.{MessageTest, UserTest}
import junit.framework.Assert._
object DatabaseTest {
/**
* Provides a temporary database file that can be deleted easily.
*/
class DatabaseContext(context: Context) extends ContextWrapper(context) {
private val dbFile = "database-test.db"
override def openOrCreateDatabase(file: String, mode: Int, factory:
SQLiteDatabase.CursorFactory, errorHandler: DatabaseErrorHandler): SQLiteDatabase = {
context.openOrCreateDatabase(dbFile, mode, factory, errorHandler)
}
def deleteDbFile() = context.deleteDatabase(dbFile)
}
}
class DatabaseTest extends AndroidTestCase {
private lazy val context = new DatabaseTest.DatabaseContext(getContext)
private lazy val database = new Database(context)
override def setUp(): Unit = {
super.setUp()
database.onMessageReceived(m1)
database.onMessageReceived(m2)
database.onMessageReceived(m3)
}
override def tearDown(): Unit = {
super.tearDown()
database.close()
context.deleteDbFile()
}
def testMessageCount(): Unit = {
val msg1 = database.getMessages(m1.header.origin, 1)
assertEquals(1, msg1.size)
val msg2 = database.getMessages(m1.header.origin, 3)
assertEquals(2, msg2.size)
}
def testMessageOrder(): Unit = {
val msg = database.getMessages(m1.header.target, 1)
assertTrue(msg.contains(m1))
}
def testMessageSelect(): Unit = {
val msg = database.getMessages(m1.header.target, 2)
assertTrue(msg.contains(m1))
assertTrue(msg.contains(m3))
}
def testMessageFields(): Unit = {
val msg = database.getMessages(m2.header.target, 1).firstKey
val header = msg.header.asInstanceOf[ContentHeader]
assertEquals(h2.origin, header.origin)
assertEquals(h2.target, header.target)
assertEquals(-1, msg.header.seqNum)
assertEquals(h2.contentType, header.contentType)
assertEquals(h2.messageId, header.messageId)
assertEquals(h2.time, header.time)
assertEquals(new CryptoData(None, None), msg.crypto)
assertEquals(m2.body, msg.body)
}
def testAddContact(): Unit = {
database.addContact(UserTest.u1)
val contacts = database.getContacts
assertEquals(1, contacts.size)
assertEquals(Option(UserTest.u1), database.getContact(UserTest.u1.address))
}
def testAddContactCallback(): Unit = {
val latch = new CountDownLatch(1)
val lbm = LocalBroadcastManager.getInstance(context)
val receiver = new BroadcastReceiver {
override def onReceive(context: Context, intent: Intent): Unit = latch.countDown()
}
lbm.registerReceiver(receiver, new IntentFilter(Database.ActionContactsUpdated))
database.addContact(UserTest.u1)
latch.await()
lbm.unregisterReceiver(receiver)
}
def testGetContact(): Unit = {
database.addContact(UserTest.u2)
assertTrue(database.getContact(UserTest.u1.address).isEmpty)
val c = database.getContact(UserTest.u2.address)
assertTrue(c.nonEmpty)
assertEquals(Option(UserTest.u2), c)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<ListView
android:id="@android:id/list"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
<TextView
android:id="@android:id/empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_contacts_found"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true" />
</RelativeLayout>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="25dp">
<ImageView
android:id="@+id/identicon"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_marginBottom="25dp" />
<TextView
android:id="@+id/address"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources translatable="false">
<string name="default_user_status">Let\'s chat!</string>
<string name="default_scan_interval">15</string>
<bool name="default_notification_sounds">true</bool>
<string name="default_max_connections">1000000</string>
</resources>

View file

@ -1,94 +0,0 @@
package com.nutomic.ensichat.activities
import android.app.AlertDialog.Builder
import android.content.DialogInterface.OnClickListener
import android.content._
import android.os.Bundle
import android.support.v4.app.NavUtils
import android.support.v4.content.LocalBroadcastManager
import android.view._
import android.widget.AdapterView.OnItemClickListener
import android.widget._
import com.nutomic.ensichat.R
import com.nutomic.ensichat.protocol.ChatService
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.views.UsersAdapter
/**
* Lists all nearby, connected devices and allows adding them to be added as contacts.
*/
class AddContactsActivity extends EnsichatActivity with OnItemClickListener {
private val Tag = "AddContactsActivity"
private lazy val database = new Database(this)
private lazy val adapter = new UsersAdapter(this)
/**
* Initializes layout, registers connection and message listeners.
*/
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
getSupportActionBar.setDisplayHomeAsUpEnabled(true)
setContentView(R.layout.activity_add_contacts)
val list = findViewById(android.R.id.list).asInstanceOf[ListView]
list.setAdapter(adapter)
list.setOnItemClickListener(this)
list.setEmptyView(findViewById(android.R.id.empty))
val filter = new IntentFilter()
filter.addAction(ChatService.ActionConnectionsChanged)
filter.addAction(Database.ActionContactsUpdated)
LocalBroadcastManager.getInstance(this)
.registerReceiver(onContactsUpdatedReceiver, filter)
}
override def onDestroy(): Unit = {
super.onDestroy()
LocalBroadcastManager.getInstance(this).unregisterReceiver(onContactsUpdatedReceiver)
}
/**
* Initiates adding the device as contact if it hasn't been added yet.
*/
override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit = {
val contact = adapter.getItem(position)
new Builder(this)
.setMessage(getString(R.string.dialog_add_contact, contact.name))
.setPositiveButton(android.R.string.yes, new OnClickListener {
override def onClick(dialog: DialogInterface, which: Int): Unit = {
database.addContact(contact)
Toast.makeText(AddContactsActivity.this, R.string.toast_contact_added, Toast.LENGTH_SHORT)
.show()
}
})
.setNegativeButton(android.R.string.no, null)
.show()
}
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case android.R.id.home =>
NavUtils.navigateUpFromSameTask(this)
true
case _ =>
super.onOptionsItemSelected(item);
}
/**
* Fetches connections and displays them (excluding contacts).
*/
private val onContactsUpdatedReceiver = new BroadcastReceiver() {
override def onReceive(context: Context, intent: Intent): Unit = {
runOnUiThread(new Runnable {
override def run(): Unit = {
adapter.clear()
(service.connections().map(a => service.getUser(a)) -- database.getContacts)
.foreach(adapter.add)
}
})
}
}
}

View file

@ -1,108 +0,0 @@
package com.nutomic.ensichat.fragments
import java.io.File
import android.app.ListFragment
import android.content.{IntentFilter, Context, BroadcastReceiver, Intent}
import android.net.Uri
import android.os.Bundle
import android.preference.PreferenceManager
import android.support.v4.content.LocalBroadcastManager
import android.view._
import android.widget.ListView
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.{AddContactsActivity, EnsichatActivity, MainActivity, SettingsActivity}
import com.nutomic.ensichat.protocol.{Crypto, ChatService}
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.views.UsersAdapter
import scala.collection.JavaConversions._
/**
* Lists all nearby, connected devices.
*/
class ContactsFragment extends ListFragment {
private lazy val adapter = new UsersAdapter(getActivity)
private lazy val database = new Database(getActivity)
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
setListAdapter(adapter)
setHasOptionsMenu(true)
getActivity.asInstanceOf[EnsichatActivity].runOnServiceConnected(() => {
database.getContacts.foreach(adapter.add)
})
LocalBroadcastManager.getInstance(getActivity)
.registerReceiver(onContactsUpdatedListener, new IntentFilter(Database.ActionContactsUpdated))
}
override def onDestroy(): Unit = {
super.onDestroy()
LocalBroadcastManager.getInstance(getActivity).unregisterReceiver(onContactsUpdatedListener)
}
override def onCreateView(inflater: LayoutInflater, container: ViewGroup,
savedInstanceState: Bundle): View =
inflater.inflate(R.layout.fragment_contacts, container, false)
override def onCreateOptionsMenu(menu: Menu, inflater: MenuInflater): Unit = {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.main, menu)
}
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case R.id.add_contact =>
startActivity(new Intent(getActivity, classOf[AddContactsActivity]))
true
case R.id.share_app =>
val pm = getActivity.getPackageManager
val ai = pm.getInstalledApplications(0).find(_.sourceDir.contains(getActivity.getPackageName))
val intent = new Intent()
intent.setAction(Intent.ACTION_SEND)
intent.setType("*/*")
intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(new File(ai.get.sourceDir)))
startActivity(intent)
true
case R.id.my_address =>
val prefs = PreferenceManager.getDefaultSharedPreferences(getActivity)
val fragment = new IdenticonFragment()
val bundle = new Bundle()
bundle.putString(
IdenticonFragment.ExtraAddress, new Crypto(getActivity).localAddress.toString)
bundle.putString(
IdenticonFragment.ExtraUserName, prefs.getString(SettingsFragment.KeyUserName, ""))
fragment.setArguments(bundle)
fragment.show(getFragmentManager, "dialog")
true
case R.id.settings =>
startActivity(new Intent(getActivity, classOf[SettingsActivity]))
true
case R.id.exit =>
getActivity.stopService(new Intent(getActivity, classOf[ChatService]))
getActivity.finish()
true
case _ =>
super.onOptionsItemSelected(item)
}
/**
* Opens a chat with the clicked device.
*/
override def onListItemClick(l: ListView, v: View, position: Int, id: Long): Unit =
getActivity.asInstanceOf[MainActivity].openChat(adapter.getItem(position).address)
private val onContactsUpdatedListener = new BroadcastReceiver() {
override def onReceive(context: Context, intent: Intent): Unit = {
getActivity.runOnUiThread(new Runnable {
override def run(): Unit = {
adapter.clear()
database.getContacts.foreach(adapter.add)
}
})
}
}
}

Some files were not shown because too many files have changed in this diff Show more