1package oonimkall
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"runtime"
9	"sync"
10
11	engine "github.com/ooni/probe-engine"
12	"github.com/ooni/probe-engine/atomicx"
13	"github.com/ooni/probe-engine/internal/runtimex"
14	"github.com/ooni/probe-engine/model"
15	"github.com/ooni/probe-engine/probeservices"
16)
17
18// AtomicInt64 allows us to export atomicx.Int64 variables to
19// mobile libraries so we can use them in testing.
20type AtomicInt64 struct {
21	*atomicx.Int64
22}
23
24// The following two variables contain metrics pertaining to the number
25// of Sessions and Contexts that are currently being used.
26var (
27	ActiveSessions = &AtomicInt64{atomicx.NewInt64()}
28	ActiveContexts = &AtomicInt64{atomicx.NewInt64()}
29)
30
31// Logger is the logger used by a Session. You should implement a class
32// compatible with this interface in Java/ObjC and then save a reference
33// to this instance in the SessionConfig object. All log messages that
34// the Session will generate will be routed to this Logger.
35type Logger interface {
36	Debug(msg string)
37	Info(msg string)
38	Warn(msg string)
39}
40
41// SessionConfig contains configuration for a Session. You should
42// fill all the mandatory fields and could also optionally fill some of
43// the optional fields. Then pass this struct to NewSession.
44type SessionConfig struct {
45	// AssetsDir is the mandatory directory where to store assets
46	// required by a Session, e.g. MaxMind DB files.
47	AssetsDir string
48
49	// Logger is the optional logger that will receive all the
50	// log messages generated by a Session. If this field is nil
51	// then the session will not emit any log message.
52	Logger Logger
53
54	// ProbeServicesURL allows you to optionally force the
55	// usage of an alternative probe service instance. This setting
56	// should only be used for implementing integration tests.
57	ProbeServicesURL string
58
59	// SoftwareName is the mandatory name of the application
60	// that will be using the new Session.
61	SoftwareName string
62
63	// SoftwareVersion is the mandatory version of the application
64	// that will be using the new Session.
65	SoftwareVersion string
66
67	// StateDir is the mandatory directory where to store state
68	// information required by a Session.
69	StateDir string
70
71	// TempDir is the mandatory directory where the Session shall
72	// store temporary files. Among other tasks, Session.Close will
73	// remove any temporary file created within this Session.
74	TempDir string
75
76	// Verbose is optional. If there is a non-null Logger and this
77	// field is true, then the Logger will also receive Debug messages,
78	// otherwise it will not receive such messages.
79	Verbose bool
80}
81
82// Session contains shared state for running experiments and/or other
83// OONI related task (e.g. geolocation). Note that the Session isn't
84// mean to be a long living object. The workflow is to create a Session,
85// do the operations you need to do with it now, then make sure it is
86// not referenced by other variables, so the Go GC can finalize it.
87//
88// Future directions
89//
90// We will eventually rewrite the code for running new experiments such
91// that a Task will be created from a Session, such that experiments
92// could share the same Session and save geolookups, etc. For now, we
93// are in the suboptimal situations where Tasks create, use, and close
94// their own session, thus running more lookups than needed.
95type Session struct {
96	cl        []context.CancelFunc
97	mtx       sync.Mutex
98	submitter *probeservices.Submitter
99	sessp     *engine.Session
100
101	// Hooks for testing (should not appear in Java/ObjC)
102	TestingCheckInBeforeNewProbeServicesClient func(ctx *Context)
103	TestingCheckInBeforeCheckIn                func(ctx *Context)
104}
105
106// NewSession creates a new session. You should use a session for running
107// a set of operations in a relatively short time frame. You SHOULD NOT create
108// a single session and keep it all alive for the whole app lifecyle, since
109// the Session code is not specifically designed for this use case.
110func NewSession(config *SessionConfig) (*Session, error) {
111	kvstore, err := engine.NewFileSystemKVStore(config.StateDir)
112	if err != nil {
113		return nil, err
114	}
115	var availableps []model.Service
116	if config.ProbeServicesURL != "" {
117		availableps = append(availableps, model.Service{
118			Address: config.ProbeServicesURL,
119			Type:    "https",
120		})
121	}
122	engineConfig := engine.SessionConfig{
123		AssetsDir:              config.AssetsDir,
124		AvailableProbeServices: availableps,
125		KVStore:                kvstore,
126		Logger:                 newLogger(config.Logger, config.Verbose),
127		SoftwareName:           config.SoftwareName,
128		SoftwareVersion:        config.SoftwareVersion,
129		TempDir:                config.TempDir,
130	}
131	sessp, err := engine.NewSession(engineConfig)
132	if err != nil {
133		return nil, err
134	}
135	sess := &Session{sessp: sessp}
136	runtime.SetFinalizer(sess, sessionFinalizer)
137	ActiveSessions.Add(1)
138	return sess, nil
139}
140
141// sessionFinalizer finalizes a Session. While in general in Go code using a
142// finalizer is probably unclean, it seems that using a finalizer when binding
143// with Java/ObjC code is actually useful to simplify the apps.
144func sessionFinalizer(sess *Session) {
145	for _, fn := range sess.cl {
146		fn()
147	}
148	sess.sessp.Close() // ignore return value
149	ActiveSessions.Add(-1)
150}
151
152// Context is the context of an operation. You use this context
153// to cancel a long running operation by calling Cancel(). Because
154// you create a Context from a Session and because the Session is
155// keeping track of the Context instances it owns, you do don't
156// need to call the Cancel method when you're done.
157type Context struct {
158	cancel context.CancelFunc
159	ctx    context.Context
160}
161
162// Cancel cancels pending operations using this context.
163func (ctx *Context) Cancel() {
164	ctx.cancel()
165}
166
167// NewContext creates an new interruptible Context.
168func (sess *Session) NewContext() *Context {
169	return sess.NewContextWithTimeout(-1)
170}
171
172// NewContextWithTimeout creates an new interruptible Context that will automatically
173// cancel itself after the given timeout. Setting a zero or negative timeout implies
174// there is no actual timeout configured for the Context.
175func (sess *Session) NewContextWithTimeout(timeout int64) *Context {
176	sess.mtx.Lock()
177	defer sess.mtx.Unlock()
178	ctx, origcancel := newContext(timeout)
179	ActiveContexts.Add(1)
180	var once sync.Once
181	cancel := func() {
182		once.Do(func() {
183			ActiveContexts.Add(-1)
184			origcancel()
185		})
186	}
187	sess.cl = append(sess.cl, cancel)
188	return &Context{cancel: cancel, ctx: ctx}
189}
190
191// GeolocateResults contains the GeolocateTask results.
192type GeolocateResults struct {
193	// ASN is the autonomous system number.
194	ASN string
195
196	// Country is the country code.
197	Country string
198
199	// IP is the IP address.
200	IP string
201
202	// Org is the commercial name of the ASN.
203	Org string
204}
205
206// MaybeUpdateResources ensures that resources are up to date.
207func (sess *Session) MaybeUpdateResources(ctx *Context) error {
208	sess.mtx.Lock()
209	defer sess.mtx.Unlock()
210	return sess.sessp.MaybeUpdateResources(ctx.ctx)
211}
212
213// Geolocate performs a geolocate operation and returns the results. This method
214// is (in Java terminology) synchronized with the session instance.
215func (sess *Session) Geolocate(ctx *Context) (*GeolocateResults, error) {
216	sess.mtx.Lock()
217	defer sess.mtx.Unlock()
218	info, err := sess.sessp.LookupLocationContext(ctx.ctx)
219	if err != nil {
220		return nil, err
221	}
222	return &GeolocateResults{
223		ASN:     fmt.Sprintf("AS%d", info.ASN),
224		Country: info.CountryCode,
225		IP:      info.ProbeIP,
226		Org:     info.NetworkName,
227	}, nil
228}
229
230// SubmitMeasurementResults contains the results of a single measurement submission
231// to the OONI backends using the OONI collector API.
232type SubmitMeasurementResults struct {
233	UpdatedMeasurement string
234	UpdatedReportID    string
235}
236
237// Submit submits the given measurement and returns the results. This method is (in
238// Java terminology) synchronized with the Session instance.
239func (sess *Session) Submit(ctx *Context, measurement string) (*SubmitMeasurementResults, error) {
240	sess.mtx.Lock()
241	defer sess.mtx.Unlock()
242	if sess.submitter == nil {
243		psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
244		if err != nil {
245			return nil, err
246		}
247		sess.submitter = probeservices.NewSubmitter(psc, sess.sessp.Logger())
248	}
249	var mm model.Measurement
250	if err := json.Unmarshal([]byte(measurement), &mm); err != nil {
251		return nil, err
252	}
253	if err := sess.submitter.Submit(ctx.ctx, &mm); err != nil {
254		return nil, err
255	}
256	data, err := json.Marshal(mm)
257	runtimex.PanicOnError(err, "json.Marshal should not fail here")
258	return &SubmitMeasurementResults{
259		UpdatedMeasurement: string(data),
260		UpdatedReportID:    mm.ReportID,
261	}, nil
262}
263
264// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test
265type CheckInConfigWebConnectivity struct {
266	CategoryCodes []string // CategoryCodes is an array of category codes
267}
268
269// Add a category code to the array in CheckInConfigWebConnectivity
270func (ckw *CheckInConfigWebConnectivity) Add(cat string) {
271	ckw.CategoryCodes = append(ckw.CategoryCodes, cat)
272}
273
274func (ckw *CheckInConfigWebConnectivity) toModel() model.CheckInConfigWebConnectivity {
275	return model.CheckInConfigWebConnectivity{
276		CategoryCodes: ckw.CategoryCodes,
277	}
278}
279
280// CheckInConfig contains configuration for calling the checkin API.
281type CheckInConfig struct {
282	Charging        bool                          // Charging indicate if the phone is actually charging
283	OnWiFi          bool                          // OnWiFi indicate if the phone is actually connected to a WiFi network
284	Platform        string                        // Platform of the probe
285	RunType         string                        // RunType
286	SoftwareName    string                        // SoftwareName of the probe
287	SoftwareVersion string                        // SoftwareVersion of the probe
288	WebConnectivity *CheckInConfigWebConnectivity // WebConnectivity class contain an array of categories
289}
290
291// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API
292type CheckInInfoWebConnectivity struct {
293	ReportID string
294	URLs     []model.URLInfo
295}
296
297// URLInfo contains info on a test lists URL
298type URLInfo struct {
299	CategoryCode string
300	CountryCode  string
301	URL          string
302}
303
304// Size returns the number of URLs.
305func (ckw *CheckInInfoWebConnectivity) Size() int64 {
306	return int64(len(ckw.URLs))
307}
308
309// At gets the URLInfo at position idx from CheckInInfoWebConnectivity.URLs
310func (ckw *CheckInInfoWebConnectivity) At(idx int64) *URLInfo {
311	if idx < 0 || int(idx) >= len(ckw.URLs) {
312		return nil
313	}
314	w := ckw.URLs[idx]
315	return &URLInfo{
316		CategoryCode: w.CategoryCode,
317		CountryCode:  w.CountryCode,
318		URL:          w.URL,
319	}
320}
321
322func newCheckInInfoWebConnectivity(ckw *model.CheckInInfoWebConnectivity) *CheckInInfoWebConnectivity {
323	if ckw == nil {
324		return nil
325	}
326	out := new(CheckInInfoWebConnectivity)
327	out.ReportID = ckw.ReportID
328	out.URLs = ckw.URLs
329	return out
330}
331
332// CheckInInfo contains the return test objects from the checkin API
333type CheckInInfo struct {
334	WebConnectivity *CheckInInfoWebConnectivity
335}
336
337// CheckIn function is called by probes asking if there are tests to be run
338// The config argument contains the mandatory settings.
339// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure.
340func (sess *Session) CheckIn(ctx *Context, config *CheckInConfig) (*CheckInInfo, error) {
341	sess.mtx.Lock()
342	defer sess.mtx.Unlock()
343	if config.WebConnectivity == nil {
344		return nil, errors.New("oonimkall: missing webconnectivity config")
345	}
346	info, err := sess.sessp.LookupLocationContext(ctx.ctx)
347	if err != nil {
348		return nil, err
349	}
350	if sess.TestingCheckInBeforeNewProbeServicesClient != nil {
351		sess.TestingCheckInBeforeNewProbeServicesClient(ctx)
352	}
353	psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
354	if err != nil {
355		return nil, err
356	}
357	if sess.TestingCheckInBeforeCheckIn != nil {
358		sess.TestingCheckInBeforeCheckIn(ctx)
359	}
360	cfg := model.CheckInConfig{
361		Charging:        config.Charging,
362		OnWiFi:          config.OnWiFi,
363		Platform:        config.Platform,
364		ProbeASN:        info.ASNString(),
365		ProbeCC:         info.CountryCode,
366		RunType:         config.RunType,
367		SoftwareVersion: config.SoftwareVersion,
368		WebConnectivity: config.WebConnectivity.toModel(),
369	}
370	result, err := psc.CheckIn(ctx.ctx, cfg)
371	if err != nil {
372		return nil, err
373	}
374	return &CheckInInfo{
375		WebConnectivity: newCheckInInfoWebConnectivity(result.WebConnectivity),
376	}, nil
377}
378