Android: get login working

This commit is contained in:
Pieter Vander Vennet 2024-12-31 19:55:08 +01:00
parent 00c233a2eb
commit 88c76498b6
16 changed files with 199 additions and 171 deletions

@ -1 +1 @@
Subproject commit b165ec6005cb5e9fcc7c1c2bb860344287f3d281
Subproject commit 917fe6a0f9ef67530f281d5603432f9c8daae0c7

View file

@ -3,6 +3,7 @@
<head><title>MapComplete Auth</title></head>
<body>
Authorizing and redirecting, hang on...
<div id="token"></div>
<script type="module" src="./land.ts"></script>
</body>
</html>

View file

@ -1,11 +1,17 @@
import { OsmConnection } from "../src/Logic/Osm/OsmConnection"
import Constants from "../src/Models/Constants"
import { Utils } from "../src/Utils"
import { UIEventSource } from "../src/Logic/UIEventSource"
import { VariableUiElement } from "../src/UI/Base/VariableUIElement"
console.log("Authorizing...")
const key = Constants.osmAuthConfig.url + "oauth2_state"
const st =window.localStorage.getItem(key )
console.log("Prev state is",key, st)
new OsmConnection().finishLogin((_, token: string) => {
console.log("Login finished, redirecting to passthrough")
const tokenSrc = new UIEventSource("")
new VariableUiElement(tokenSrc).AttachTo("token")
new OsmConnection().finishLogin(async (_, token: string) => {
console.log("Login finished, redirecting to passthrough; token is "+token)
await Utils.waitFor(10)
window.location.href = "https://app.mapcomplete.org/passthrough.html?oauth_token="+token
})

View file

@ -49,6 +49,7 @@ cp -r dist/assets/templates dist-full/assets
cp -r dist/assets/themes dist-full/assets
# mkdir dist-full/assets/generated
nvm use
# assets/icon-only.png will be used as the app icon
# See https://capacitorjs.com/docs/guides/splash-screens-and-icons
@ -56,4 +57,4 @@ npx capacitor-assets generate
npx cap sync
echo "All done! Don't forget to click 'gradly sync files' in Android Studio"
echo "All done! Don't forget to click 'gradle sync files' in Android Studio"

View file

@ -6,6 +6,7 @@ import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
export interface ChangesetTag {
key: string
@ -52,7 +53,7 @@ export class ChangesetHandler {
| { addAlias: (id0: string, id1: string) => void }
| undefined,
changes: Changes,
reportError: (e: string | Error, extramessage: string) => void
reportError: (e: string | Error, extramessage: string) => void,
) {
this.osmConnection = osmConnection
this._reportError = reportError
@ -113,7 +114,7 @@ export class ChangesetHandler {
private async UploadWithNew(
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
openChangeset: UIEventSource<number>,
extraMetaTags: ChangesetTag[]
extraMetaTags: ChangesetTag[],
) {
const csId = await this.OpenChangeset(extraMetaTags)
openChangeset.setData(csId)
@ -121,7 +122,7 @@ export class ChangesetHandler {
console.log(
"Opened a new changeset (openChangeset.data is undefined):",
changeset,
extraMetaTags
extraMetaTags,
)
const changes = await this.UploadChange(csId, changeset)
const hasSpecialMotivationChanges = ChangesetHandler.rewriteMetaTags(extraMetaTags, changes)
@ -144,7 +145,7 @@ export class ChangesetHandler {
public async UploadChangeset(
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
extraMetaTags: ChangesetTag[],
openChangeset: UIEventSource<number>
openChangeset: UIEventSource<number>,
): Promise<void> {
if (
!extraMetaTags.some((tag) => tag.key === "comment") ||
@ -179,13 +180,13 @@ export class ChangesetHandler {
try {
const rewritings = await this.UploadChange(
csId,
generateChangeXML(csId, this._remappings)
generateChangeXML(csId, this._remappings),
)
const rewrittenTags = this.RewriteTagsOf(
extraMetaTags,
rewritings,
oldChangesetMeta
oldChangesetMeta,
)
await this.UpdateTags(csId, rewrittenTags)
return // We are done!
@ -196,7 +197,7 @@ export class ChangesetHandler {
} catch (e) {
this._reportError(
e,
"While getting metadata from a changeset " + openChangeset.data
"While getting metadata from a changeset " + openChangeset.data,
)
}
}
@ -224,7 +225,7 @@ export class ChangesetHandler {
console.warn(
"Could not open/upload changeset due to ",
e,
"trying again with a another fresh changeset "
"trying again with a another fresh changeset ",
)
openChangeset.setData(undefined)
@ -250,7 +251,7 @@ export class ChangesetHandler {
uid: number // User ID
changes_count: number
tags: any
}
},
): ChangesetTag[] {
// Note: extraMetaTags is where all the tags are collected into
@ -387,7 +388,7 @@ export class ChangesetHandler {
tag.key !== undefined &&
tag.value !== undefined &&
tag.key !== "" &&
tag.value !== ""
tag.value !== "",
)
const metadata = tags.map((kv) => `<tag k="${kv.key}" v="${escapeHtml(kv.value)}"/>`)
const content = [`<osm><changeset>`, metadata, `</changeset></osm>`].join("")
@ -398,10 +399,16 @@ export class ChangesetHandler {
const usedGps = this.changes.state["currentUserLocation"]?.features?.data?.length > 0
const hasMorePrivacy = !!this.changes.state?.featureSwitches?.featureSwitchMorePrivacy?.data
const setSourceAsSurvey = !hasMorePrivacy && usedGps
let shell = ""
let host = `${window.location.origin}${window.location.pathname}`
if (AndroidPolyfill.inAndroid.data) {
shell = " (Android)"
host = "https://mapcomplete.org/" + window.location.pathname
}
return [
["created_by", `MapComplete ${Constants.vNumber}`],
["created_by", `MapComplete ${Constants.vNumber}${shell}`],
["locale", Locale.language.data],
["host", `${window.location.origin}${window.location.pathname}`], // Note: deferred changes might give a different hostpath then the theme with which the changes were made
["host", host], // Note: deferred changes might give a different hostpath then the theme with which the changes were made
["source", setSourceAsSurvey ? "survey" : undefined],
["imagery", this.changes.state["backgroundLayer"]?.data?.id],
].map(([key, value]) => ({
@ -427,7 +434,7 @@ export class ChangesetHandler {
const csId = await this.osmConnection.put(
"changeset/create",
[`<osm><changeset>`, metadata, `</changeset></osm>`].join(""),
{ "Content-Type": "text/xml" }
{ "Content-Type": "text/xml" },
)
return Number(csId)
}
@ -437,12 +444,12 @@ export class ChangesetHandler {
*/
private async UploadChange(
changesetId: number,
changesetXML: string
changesetXML: string,
): Promise<Map<string, string>> {
const response = await this.osmConnection.post<XMLDocument>(
"changeset/" + changesetId + "/upload",
changesetXML,
{ "Content-Type": "text/xml" }
{ "Content-Type": "text/xml" },
)
const changes = this.parseUploadChangesetResponse(response)
console.log("Uploaded changeset ", changesetId)

View file

@ -5,41 +5,70 @@ import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { AuthConfig } from "./AuthConfig"
import Constants from "../../Models/Constants"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
import { Feature, Point } from "geojson"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
import { QueryParameters } from "../Web/QueryParameters"
interface OsmUserInfo {
id: number
display_name: string
account_created: string
description: string
contributor_terms: { agreed: boolean }
roles: []
changesets: { count: number }
traces: { count: number }
blocks: { received: { count: number; active: number } }
}
export default class UserDetails {
public loggedIn = false
public name = "Not logged in"
public uid: number
public csCount = 0
public img?: string
public unreadMessages = 0
public totalMessages: number = 0
public home: { lon: number; lat: number }
public backend: string
public account_created: string
public tracesCount: number = 0
public description: string
public languages: string[]
constructor(backend: string) {
this.backend = backend
"id": number,
"display_name": string,
"account_created": string,
"description": string,
"contributor_terms": {
"agreed": boolean,
"pd": boolean
},
"img": {
"href": string,
},
"roles": string[],
"changesets": {
"count": number
},
"traces": {
"count": number
},
"blocks": {
"received": {
"count": number,
"active": number
}
},
"home": {
"lat": number,
"lon": number,
"zoom": number
},
"languages": string[],
"messages": {
"received": {
"count": number,
"unread": number
},
"sent": {
"count": number
}
}
}
export default interface UserDetails {
loggedIn: boolean
name: string
uid: number
csCount: number
img?: string
unreadMessages: number
totalMessages: number
home?: { lon: number; lat: number }
backend: string
account_created: string
tracesCount: number
description?: string
languages: string[]
}
export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable"
interface CapabilityResult {
@ -77,7 +106,10 @@ interface CapabilityResult {
export class OsmConnection {
public auth: osmAuth
public userDetails: UIEventSource<UserDetails>
/**
* Details of the currently logged-in user; undefined if not logged in
*/
public userDetails: UIEventSource<UserDetails | undefined>
public isLoggedIn: Store<boolean>
public gpxServiceIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>(
"unknown",
@ -93,7 +125,6 @@ export class OsmConnection {
public readonly _oauth_config: AuthConfig
private readonly _dryRun: Store<boolean>
private readonly fakeUser: boolean
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []
private readonly _iframeMode: boolean
private readonly _singlePage: boolean
private isChecking = false
@ -129,10 +160,7 @@ export class OsmConnection {
this._oauth_config.oauth_secret = import.meta.env.VITE_OSM_OAUTH_SECRET
}
this.userDetails = new UIEventSource<UserDetails>(
new UserDetails(this._oauth_config.url),
"userDetails",
)
this.userDetails = new UIEventSource<UserDetails>(undefined, "userDetails")
if (options.fakeUser) {
const ud = this.userDetails.data
ud.csCount = 5678
@ -146,25 +174,21 @@ export class OsmConnection {
"The 'fake-user' is a URL-parameter which allows to test features without needing an OSM account or even internet connection."
this.loadingStatus.setData("logged-in")
}
this.UpdateCapabilities()
this.updateCapabilities()
this.isLoggedIn = this.userDetails.map(
(user) =>
user.loggedIn &&
!!user &&
(this.apiIsOnline.data === "unknown" || this.apiIsOnline.data === "online"),
[this.apiIsOnline],
)
this.isLoggedIn.addCallback((isLoggedIn) => {
if (this.userDetails.data.loggedIn == false && isLoggedIn == true) {
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
// This means someone attempted to toggle this; so we attempt to login!
this.AttemptLogin()
}
})
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.updateAuthObject()
this.createAuthObject()
AndroidPolyfill.inAndroid.addCallback(() => {
this.createAuthObject()
})
if (!this.fakeUser) {
this.CheckForMessagesContinuously()
}
@ -209,9 +233,6 @@ export class OsmConnection {
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
}
public OnLoggedIn(action: (userDetails: UserDetails) => void) {
this._onLoggedIn.push(action)
}
public LogOut() {
this.auth.logout()
@ -233,7 +254,7 @@ export class OsmConnection {
}
public AttemptLogin() {
this.UpdateCapabilities()
this.updateCapabilities()
if (this.loadingStatus.data !== "logged-in") {
// Stay 'logged-in' if we are already logged in; this simply means we are checking for messages
this.loadingStatus.setData("loading")
@ -245,18 +266,51 @@ export class OsmConnection {
}
console.log("Trying to log in...")
this.updateAuthObject()
LocalStorageSource.get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href,
)
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/details",
},
(err, details: XMLDocument) => {
if (err != null) {
this.auth.authenticate((err, result) => {
if (!err) {
this.loadUserInfo()
}
})
}
private async loadUserInfo() {
try {
const result = await this.interact("user/details.json")
if (result === null) {
this.loadingStatus.setData("error")
return
}
const data = <{
"version": "0.6",
"license": "http://opendatacommons.org/licenses/odbl/1-0/",
"user": OsmUserInfo
}>JSON.parse(result)
const user = data.user
const userdetails: UserDetails = {
uid: user.id,
name: user.display_name,
csCount: user.changesets.count,
description: user.description,
loggedIn: true,
backend: this.Backend(),
home: user.home,
languages: user.languages,
totalMessages: user.messages.received?.count ?? 0,
img: user.img?.href,
account_created: user.account_created,
tracesCount: user.traces.count,
unreadMessages: user.messages.received?.unread ?? 0,
}
console.log("Login completed, userinfo is ", userdetails)
this.userDetails.set(userdetails)
this.loadingStatus.setData("logged-in")
} catch (err) {
console.log("Could not login due to:", err)
this.loadingStatus.setData("error")
if (err.status == 401) {
@ -268,64 +322,7 @@ export class OsmConnection {
console.log("Other error. Status:", err.status)
this.apiIsOnline.setData("unreachable")
}
return
}
if (details == null) {
this.loadingStatus.setData("error")
return
}
// details is an XML DOM of user details
const userInfo = details.getElementsByTagName("user")[0]
const data = this.userDetails.data
data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo)
data.name = userInfo.getAttribute("display_name")
data.account_created = userInfo.getAttribute("account_created")
data.uid = Number(userInfo.getAttribute("id"))
data.languages = Array.from(
userInfo.getElementsByTagName("languages")[0].getElementsByTagName("lang"),
).map((l) => l.textContent)
data.csCount = Number.parseInt(
userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? "0",
)
data.tracesCount = Number.parseInt(
userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? "0",
)
data.img = undefined
const imgEl = userInfo.getElementsByTagName("img")
if (imgEl !== undefined && imgEl[0] !== undefined) {
data.img = imgEl[0].getAttribute("href")
}
const description = userInfo.getElementsByTagName("description")
if (description !== undefined && description[0] !== undefined) {
data.description = description[0]?.innerHTML
}
const homeEl = userInfo.getElementsByTagName("home")
if (homeEl !== undefined && homeEl[0] !== undefined) {
const lat = parseFloat(homeEl[0].getAttribute("lat"))
const lon = parseFloat(homeEl[0].getAttribute("lon"))
data.home = { lat: lat, lon: lon }
}
this.loadingStatus.setData("logged-in")
const messages = userInfo
.getElementsByTagName("messages")[0]
.getElementsByTagName("received")[0]
data.unreadMessages = parseInt(messages.getAttribute("unread"))
data.totalMessages = parseInt(messages.getAttribute("count"))
this.userDetails.ping()
for (const action of this._onLoggedIn) {
action(this.userDetails.data)
}
this._onLoggedIn = []
},
)
}
/**
@ -339,7 +336,7 @@ export class OsmConnection {
*/
public async interact(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE",
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
header?: Record<string, string>,
content?: string,
allowAnonymous: boolean = false,
@ -359,6 +356,11 @@ export class OsmConnection {
throw "Could not interact with OSM:" + possibleResult["error"]
}
if (!this.auth.authenticated()) {
console.trace("Not authenticated")
await Utils.waitFor(10000)
}
return new Promise((ok, error) => {
connection.xhr(
{
@ -504,7 +506,7 @@ export class OsmConnection {
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
"\"\r\nContent-Type: application/gpx+xml",
}
user
const boundary = "987654"
let body = ""
@ -563,19 +565,29 @@ export class OsmConnection {
this.auth.authenticate(() => {
// Fully authed at this point
console.log("Authentication successful!")
const oauth_token = window.localStorage.getItem(this._oauth_config.url + "oauth2_access_token")
const oauth_token = QueryParameters.GetQueryParameter("oauth_token", undefined).data ?? window.localStorage.getItem(this._oauth_config.url + "oauth2_access_token")
const previousLocation = LocalStorageSource.get("location_before_login")
callback(previousLocation.data, oauth_token)
})
}
private updateAuthObject() {
private async loginAndroidPolyfill() {
const token = await AndroidPolyfill.requestLoginCodes()
console.log("Got login token!", token)
localStorage.setItem("https://www.openstreetmap.orgoauth2_access_token", token)
if (this.auth.authenticated()) {
console.log("Logged in!")
}
await this.loadUserInfo()
}
private createAuthObject() {
let redirect_uri = Utils.runningFromConsole
? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html"
if (AndroidPolyfill.inAndroid.data) {
redirect_uri = "https://app.mapcomplete.org/land.html"
AndroidPolyfill.requestLoginCodes(this)
}
this.auth = new osmAuth({
client_id: this._oauth_config.oauth_client_id,
@ -586,9 +598,12 @@ export class OsmConnection {
* However, this breaks in iframes so we open a popup in that case
*/
singlepage: !this._iframeMode && !AndroidPolyfill.inAndroid.data,
auto: true,
auto: false,
apiUrl: this._oauth_config.api_url ?? this._oauth_config.url,
})
if (AndroidPolyfill.inAndroid.data) {
this.loginAndroidPolyfill() // NO AWAIT!
}
}
private CheckForMessagesContinuously() {
@ -611,9 +626,10 @@ export class OsmConnection {
return
}
Stores.Chronic(60 * 5 * 1000).addCallback(() => {
// Check for new messages every 5 minutes
if (this.isLoggedIn.data) {
try {
this.AttemptLogin()
this.loadUserInfo()
} catch (e) {
console.log("Could not login due to", e)
}
@ -621,11 +637,11 @@ export class OsmConnection {
})
}
private UpdateCapabilities(): void {
private updateCapabilities(): void {
if (this.fakeUser) {
return
}
this.FetchCapabilities().then(({ api, gpx }) => {
this.fetchCapabilities().then(({ api, gpx }) => {
this.apiIsOnline.setData(api)
this.gpxServiceIsOnline.setData(gpx)
})
@ -646,7 +662,8 @@ export class OsmConnection {
return parsed
}
private async FetchCapabilities(): Promise<{
/**Does not use the OSM-auth object*/
private async fetchCapabilities(): Promise<{
api: OsmServiceState
gpx: OsmServiceState
database: OsmServiceState
@ -656,7 +673,7 @@ export class OsmConnection {
}
try {
const result = await Utils.downloadJson<CapabilityResult>(
this.Backend() + "/api/0.6/capabilities.json"
this.Backend() + "/api/0.6/capabilities.json",
)
if (result?.api?.status === undefined) {
console.log("Something went wrong:", result)

View file

@ -32,7 +32,7 @@ export class OsmPreferences {
this.auth = auth
this._fakeUser = fakeUser
this.osmConnection = osmConnection
osmConnection.OnLoggedIn(() => {
osmConnection.userDetails.addCallbackAndRunD(() => {
this.loadBulkPreferences()
return true
})

View file

@ -7,6 +7,7 @@ import themeOverview from "../../assets/generated/theme_overview.json"
import LayerSearch from "./LayerSearch"
import SearchUtils from "./SearchUtils"
import { OsmConnection } from "../Osm/OsmConnection"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
type ThemeSearchScore = {
theme: MinimalThemeInformation
@ -82,7 +83,7 @@ export default class ThemeSearch {
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
if ((location.hostname === "localhost" && !AndroidPolyfill.inAndroid.data) || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}

View file

@ -182,9 +182,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
let testingDefaultValue = false
if (
!Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1") &&
!Constants.osmAuthConfig.url.startsWith("https://master.apis.dev.openstreetmap.org")
!Constants.osmAuthConfig.url.startsWith("https://master.apis.dev.openstreetmap.org") && (location.hostname === "127.0.0.1") && !Utils.runningFromConsole
) {
testingDefaultValue = true
}

View file

@ -51,13 +51,11 @@ export class AndroidPolyfill {
this.backfillGeolocation(this.databridgePlugin)
}
public static async requestLoginCodes(osmConnection: OsmConnection) {
public static async requestLoginCodes() {
const result = await DatabridgePluginSingleton.request<{oauth_token: string}>({ key: "request:login" })
const token: string = result.value.oauth_token
console.log("AndroidPolyfill: received code and state; trying to pass them to the oauth lib",token)
const auth = osmConnection.auth.bootstrapToken(token, (err, result) => {
console.log("AndroidPolyFill: bootstraptoken returned", JSON.stringify({err, result}))
})
console.log("AndroidPolyfill: received oauth_token; trying to pass them to the oauth lib",token)
return token
}
}

View file

@ -41,7 +41,7 @@
const tu = Translations.t.general
const tr = Translations.t.general.morescreen
let userLanguages = osmConnection.userDetails.map((ud) => ud.languages)
let userLanguages = osmConnection.userDetails.mapD((ud) => ud.languages)
let search: UIEventSource<string | undefined> = new UIEventSource<string>("")
let searchStable = search.stabilized(100)
@ -58,7 +58,7 @@
hiddenThemes.filter(
(theme) =>
knownIds.indexOf(theme.id) >= 0 ||
state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
state.osmConnection.userDetails.data?.name === "Pieter Vander Vennet"
)
)

View file

@ -14,7 +14,7 @@
josmState.stabilized(15000).addCallbackD(() => josmState.setData(undefined))
const showButton = state.osmConnection.userDetails.map(
(ud) => ud.loggedIn && ud.csCount >= Constants.userJourney.historyLinkVisible
(ud) => ud?.csCount >= Constants.userJourney.historyLinkVisible
)
function openJosm() {

View file

@ -44,7 +44,7 @@
const imageInfo = await panoramax.imageInfo(image.id)
let reporter_email: string = undefined
const userdetails = state.userRelatedState.osmConnection.userDetails
if (userdetails.data.loggedIn) {
if (!userdetails.data) {
reporter_email = userdetails.data.name + "@openstreetmap.org"
}

View file

@ -55,7 +55,7 @@ export class DeleteFlowState {
if (ud === undefined) {
return undefined
}
if (!ud.loggedIn) {
if (!this._osmConnection.isLoggedIn.data) {
return false
}
return (

View file

@ -23,7 +23,7 @@
$: tagsExplanation = tags?.asHumanString(true, false, currentProperties)
</script>
{#if !userDetails || $userDetails.loggedIn}
{#if !userDetails}
<div class="break-words" style="word-break: break-word">
{#if tags === undefined}
<slot name="no-tags"><Tr cls="subtle" t={Translations.t.general.noTagsSelected} /></slot>

View file

@ -1,6 +1,5 @@
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import Test from "./UI/Test.svelte"
import { OsmConnection } from "./Logic/Osm/OsmConnection"
new Test({
target: document.getElementById("maindiv"),
})
new OsmConnection().interact("user/details.json").then(r => console.log(">>>", r))